diff --git a/.opencode/skills/unslop/SKILL.md b/.opencode/skills/unslop/SKILL.md new file mode 100644 index 0000000..9a1c49e --- /dev/null +++ b/.opencode/skills/unslop/SKILL.md @@ -0,0 +1,233 @@ +--- +name: unslop +description: Turns vibecoded, AI-looking UI into a designed interface with its own character. Finds a direction from the product itself, then fixes spacing, color, icons, copy, type, layout, details, visuals and motion in that order, and can generate a real hero visual with an image tool when one is available. Use when the user asks to unslop, polish, clean up or redesign a screen so it stops looking AI-generated, or when a UI shows the usual tells like purple gradients, glows, gradient text, emoji labels, "New โœจ" badges, glossy blobs, uppercase tracked labels, pulsing elements or uneven spacing. Works on React, Tailwind, HTML and CSS, for dashboards, apps and landing pages. +license: CC-BY-4.0 +metadata: + author: Damir Sokolovsky + x: https://x.com/heydamir + site: https://heydamir.design/unslop +--- + +# Unslop + +unslop was created by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), who started the Unslopping series on X. It is licensed under CC BY 4.0, so keep this credit in any copy or adaptation. + +Vibecoded UI fails in the same predictable ways. Every surface glows, every label has an emoji, spacing is whatever the model typed that day, and the copy shouts. Unslopping fixes the craft and keeps the product. The features, content and order of sections stay. What changes is that every size, color, word and image becomes deliberate. + +Removing slop is only half the job. A screen that is clean but could belong to any product is the next template. Every run ends with a screen that only this product could have. + +The difference between slop and craft is where the richness goes. Slop spreads color and effects over the chrome, the backgrounds, buttons, borders and labels. Craft keeps the chrome quiet and puts the richness into one or two moments of content that people remember, like a rendered object, a beautiful chart or an image from the product's world. + +Work like a designer doing a redline pass, one layer at a time, across the whole screen. + +## What good looks like + +- **One bright element.** Each screen has one polished thing that carries the color and the craft, a rendered object, a painted scene or a rich chart. Everything around it is quiet, so it reads instantly. +- **Quiet chrome.** Navigation, buttons and text use the tinted neutrals and one accent. Nothing glows, nothing competes with the bright element. +- **A clear idea.** You can say in one sentence what the visual shows and why it belongs to this product. +- **Text on calm space.** The headline sits on a part of the image or layout that was left empty for it, short and specific, with one primary and one secondary action and nothing stacked under them. +- **Real product, real material.** Previews look like the real app with plausible data, and objects look like real things with weight, light and shadow. + +Which visual styles tend to work, and which fail, is in [references/visuals.md](references/visuals.md). Finished examples to learn from are in [references/refs.md](references/refs.md), and code treatments like drawing an image in glyphs are in [references/effects.md](references/effects.md). + +## Run it + +1. **Look at the screen.** Find the files that render it. If you can run the app or open the file in a browser and take a screenshot, do it, so you judge what people see and not only the code. + - Only a link to a live site, no code? Save the page with `node /scripts/snapshot.mjs unslop/original.html` and work on a copy of that file. Add `--dark` or `--light` to match the theme the user sees. It also saves `original.live.png`, a screenshot of the live page, so render the saved copy and compare the two before you start. Animated backgrounds drawn by scripts do not survive, so describe them in the audit from the live page. + - Only a screenshot? Rebuild the screen as one static HTML file first, matching it closely, and treat that as the original. + - In both cases the result is a redesigned page in code that the user can hand to whoever owns the real site, so say that in the report. +2. **Audit.** Go through [references/signs.md](references/signs.md) and list every sign you find, one line each with where it lives. +3. **Decide how much to change.** Read the audit honestly before planning anything. + - **Heavy slop**, many signs across color, type, icons and copy. Run the full process below. + - **Light slop**, a screen that is mostly designed with a few signs left. Polish mode. Fix only what the audit found, take the direction from the existing brand instead of inventing a new one, and keep the palette, type, layout and visual. Bigger ideas, like a moment that is missing or a scene for the hero, go into the report as suggestions, not changes. + - **No slop.** Say so plainly and suggest two or three small refinements at most, with exact values. Apply them only if the user agrees, and stop there. Never change a good screen just to make it different. +4. **Set the direction and stop.** Write the five lines described below. Show the audit and the direction to the user and wait for their answer before editing, since changing the direction later means redoing everything. If you have a tool for asking the user questions, ask through it, otherwise ask in plain text and stop. Skip the wait only when the user already said to just do it. +5. **Fix in nine passes, in order.** Finish a pass across the whole screen before starting the next. When nearly every line changes, plan the passes in order and then write the new screen in one go, as long as every pass is applied. Spacing comes first because everything else reads differently once the rhythm is right. Copy comes before type and layout because the length of the headline decides its size and where it sits. Visuals and motion come last because they should fit a screen that already works without them. +6. **Check.** Run the checklist at the end of this file, render again and compare with the audit list. Anything still on the list gets fixed. +7. **Report** the direction, a few lines per pass about what changed, and any image prompt you wrote. +8. **Record, when asked or when the user wants something to share.** Build the story file and render the video described in [Story video](#story-video). If the user asks for it at the start, write each pass into the story file as you go. + +Keep a run quick. Render the screen to check it at most three times. When you generate an image, start it right after the direction is confirmed and keep doing the first passes while it runs, instead of waiting for it. + +Exact values for every pass live in [references/tokens.md](references/tokens.md). + +## Direction + +Before touching anything, write five short lines. Derive them from what the page says the product does and who it is for. Never from what is trending. + +- **Product.** What it does and for whom, in one plain sentence. +- **Character.** Two or three concrete words, like `calm, exact, a little warm` or `fast, technical, playful`. Words like clean, modern, sleek, minimal and premium are not allowed, because they describe every template. +- **World.** The place or material the product naturally belongs to. A note app lives near paper and ink, a monitoring tool near instruments and night shifts, a travel app near maps and window light. This world sets the tint of the neutrals, the accent and the visual. +- **Moments.** One or two places where the screen gets rich color, material and detail, chosen from what the product shows. A rendered card or device inside the preview, a chart whose colors each mean a series, an object from the world placed next to the content. Write what they are. +- **Signature.** One small detail only this product would have, used consistently. A live status dot for a monitoring tool, keycaps for a developer tool, hand drawn underlines for a teaching app, a route line for a delivery app. One detail, not five. + +The examples here only show the shape of an answer. Derive yours from the product in front of you, even when an example happens to fit. + +If the product already has a real brand, like a logo file, a color or a typeface that is not slop, keep it and build the direction around it. A violet to blue gradient, sparkles and glow are slop even when they sit in the logo, so they do not count as brand unless the user says they do. + +## 1. Spacing + +- Everything sits on a 4px grid. Margins, paddings, gaps and fixed heights are multiples of 4, so 28 or 104 are fine even though they are not in the token list. A 2px step is fine for nudges inside small controls, and an odd value that exactly centers something stays. +- Same thing, same size. All rows in a list share one height and one gap, all cards in a row share one padding. +- Space shows grouping. Space inside a group is smaller than space between groups, so a list reads as one block and sections read as separate ones. +- Things line up. Icons sit in one column, labels start on one line, numbers align right in one column. Nothing is nudged off the line with a stray translate or odd margin. +- Padding follows density. Compact panels and sidebars around 16, cards 20 to 24, page edges 24 to 32. +- Navigation is the densest part of an app. Rows are 32 to 36 tall with 2 to 4 between them, icons in one column, labels on one line. A section label sits closer to its own items than to the section above it, like 24 above and 8 below. Measure the sidebar after the pass, it is where spacing breaks most often. +- Remove one-off values that break the rhythm, like `mt-[13px]` or `py-[11px]`, and replace them with steps from the scale. + +## 2. Color + +- Build from one neutral ramp, tinted toward the world from the direction. Pick graphite, warm or ink from the tokens, or tint your own the same way. Background, raised surfaces, borders and text all come from that ramp. +- In apps and dashboards the backgrounds, surfaces, borders and text stay neutral, the graphite ramp or something just as grey. People look at them for hours and the content has to carry the color. A screen whose background reads as orange, brown, blue or green has gone too far. The world shows in the accent, the charts and the moments, not in the surfaces. Landing pages can tint their surfaces further toward the world. +- Three text roles, primary, secondary and muted. Not ten slightly different lavenders. +- In the chrome, one accent, used rarely. It marks the primary action, the selection or the one number that matters. +- The moments from the direction are the exception. Inside them color can be rich, several hues in a chart, a rendered object with real materials, an image with its own light, as long as it stays inside the world and each color means something. +- If the brand has a real color, it becomes the accent and leaves the surfaces. If there is no real brand color, take the accent from the world. +- Accent with text on it reaches 4.5 to 1 contrast. If it does not, darken it or put dark text on it. Accent used as text or ink on a light surface needs the same contrast, so use a darker shade of it there. +- On dark themes the primary button can be an off white from the ramp with dark text, and the accent can live in a smaller place like the selection or a key figure. This often looks more deliberate than a colored button. +- Semantic colors only carry meaning, like green for a good change and red for an error, and they are toned down to sit inside the palette. +- Remove hue gradients on surfaces, gradient text, colored glows and colored shadows, radial color washes and blobs behind content, and neon borders. +- Borders are quiet hairlines, the text color of the ramp at 6 to 10% opacity. +- On dark themes depth comes from surface steps and hairlines, not from shadows. A window or object floating over an image may keep a soft neutral shadow. On light themes shadows are neutral, soft and small. +- A gradient is allowed only as a small tone shift inside one neutral, like an active row going from #2b2b2b to #1f1f1f. Never from one hue to another. + +## 3. Icons + +- Every emoji used as an icon or as decoration in a label becomes a line icon from one set. Use the set the project already has, otherwise lucide. +- One size and one stroke everywhere, 16 or 18px with a 1.5 stroke. +- Icons take the color of their text role, usually secondary or muted. They never get their own colors. +- The icon box has the same width in every row and the gap to the label is fixed, 8 or 10, so labels align. +- Drop icons that add nothing, like an emoji in every card title or before every section label. Categories that need color get a small status dot. + +## 4. Copy + +- Headlines say what the product does for whom, in plain words, so they could not fit another product. Use the facts already on the page and do not invent new ones. +- No exclamation marks in interface text, and never `!!`. +- No greeting headlines like `Welcome back, Damir! ๐Ÿ‘‹`. The title names the context, like the page name or the period, for example `October 2026`. +- Short labels. `Search`, not `Search anything...`. +- A change says what it compares to, like `+12% vs last month` or `3 fewer vs last month`. +- Headlines, subheads, labels, buttons and captions end without a period. Only paragraphs of several sentences keep their periods. +- No hype words like supercharge, seamless, unlock, magic, revolutionize or game-changing, and no `AI-powered` on everything. +- Claims that cannot be checked, like `Loved by 10,000+ teams` or `10x faster`, become quiet and specific if the user confirms they are real, and are removed otherwise. Ask, and list them in the report. +- Mock data looks real, with plausible numbers and names, never `[X]` or `123,456`. +- Badges like `PRO`, `New โœจ` or `Beta ๐Ÿš€` go away unless the product really needs them, and then they are quiet text. + +## 5. Type + +- Choose type for the character in the direction, from the families in the tokens. Keep the project's font when it is part of a real brand. A default like Inter that nobody chose may be swapped when another family fits the character better. One family for the interface, plus at most one partner from any row of the tokens, a mono for figures and meta or a display face for large headlines. +- A few sizes from a scale and two weights, 400 and 500, with 600 at most for rare emphasis. No 700 to 900 headings, no gradient text. +- Hierarchy comes from size and color, not from weight. +- Sentence case everywhere. Labels like `NAVIGATION` or `PAID THIS MONTH` with wide tracking become `Navigation` and `Paid this month`. +- Small text never gets negative letter spacing. Readability on small text means bigger, a bit bolder or slightly looser. Tight tracking is only for large display text. +- Numbers use `tabular-nums` and one format across the screen. If one figure is `$48,210`, the next is not `$124,860.40`. +- No italic for emphasis. + +## 6. Layout + +- Keep the sections and their order. Inside a section, alignment and proportions may change when that makes the screen clearer. +- Apps and dashboards keep real app proportions. The shell fills the viewport, a sidebar of 240 to 280 and fluid content, usually capped around 1440 to 1600 wide, and the page is as long as its content, with more sections below the first screen. Never shrink an app into a narrow centered column. If the original was drawn as a small window on a backdrop, keep it visibly a window, with its frame and backdrop, and still give it desktop proportions inside. +- The strongest landing hero is a scene. One generated image fills the hero from edge to edge, its subject sits on one side, and the headline sits on the calm empty part of the same image, with the product view rising from the bottom edge. Look at the hero in the references to see it. +- Without a scene, use an asymmetric split, text on one side and the visual given at least half the width, or text over a large product view below it. Everything stacked and centered is the template default, keep it only for a short hero with no visual. +- In a scene hero, the text block sits in the upper part, in the space between the navigation and the top of the product view, at its optical center, a little above the true middle. +- In a split, center the text block vertically against the visual, so neither side leaves a big empty corner. If the visual is much taller, shorten it instead of pushing the text down. Floating cards and devices next to the text are hard to balance, prefer one solid visual. The spacing token from the navigation to the headline applies to stacked heroes, a split hero is centered instead. +- Long headlines align left. Centered text works for one or two short lines. +- One primary action and at most one secondary action next to it. Anything else moves out of the hero. +- Navigation stays quiet. Logo, the existing links, one button. Log in is a quiet text link next to it, not a second button. A floating pill header is not required. On small screens the links collapse into one menu button. +- Compose the first screen as a whole. The hero fills the viewport height without a dead empty band. If the content is shorter, center it or let the product view start low and run past the bottom edge, so the page invites a scroll. +- On small screens everything stacks in reading order, the headline drops to the mobile size from the tokens and the visual comes after the actions. A dense preview gets simpler on mobile, with fewer rows and columns, instead of being zoomed down. No text in it goes below 11px. + +## 7. Details + +- Nested radius. An inner radius equals the outer radius minus the padding between them, never less than 4. Controls 8 to 10, cards 12 to 16, panels 14 to 20. Not everything is a pill. +- Counts and meta become quiet text, often mono and muted, instead of glowing pills. +- Keyboard shortcuts become small keycaps. +- Charts sit on a muted grid with muted mono labels. No rainbow bars inside one series, no glowing bars, no uppercase day labels. If the chart is placeholder noise, replace it with data that tells something real. +- Charts have a hierarchy, like text. One main chart is clearly the biggest thing on the screen, drawn with care, like smooth lines or a mirrored comparison. Anything else that shows data is smaller and different in kind, like a ranked list with thin bars or a short table, never a second chart of the same size. +- One accent per chart. The thing that matters, like the current month, the line you are tracking or the plan that grew, gets the accent, and everything else sits in neutral steps of the ramp. Several hues only when the chart really has to tell series apart, and even then one leads and the rest are quiet. +- Stat cards are numbers. A label, a big figure and a change with what it compares to. No sparklines, tallies, mini bars or icons added to fill them, they read as decoration. A card can hold a real looking object when it is the very thing its number counts, like a paid receipt next to paid revenue or bank cards next to spend. Use as many as make sense for that dashboard, as long as each one reads on its own and none of them is a mini chart. +- Vary the sizes. Cards of equal width side by side, each with the same kind of content, read as a template. Give the grid a rhythm, like three or four compact stats in a row above one wide chart, or a wide chart next to a narrow list. +- Pick the chart type for the data. A trend is a line, a comparison is bars, a share is one stacked bar or a list with bars, a single goal is a progress bar. +- The active state is a small surface step with a hairline highlight, not a gradient with a glow. +- Hover and focus states exist and stay inside the palette. A visible focus ring for keyboard users. +- The logo is the product name in the main family, plus a simple mark if there is one. No gradient wordmark and no sparkle tile. +- The signature detail from the direction appears where it belongs and nowhere else. + +## 8. Visuals + +- Decorative filler goes. Glossy blobs, orbs, nebulas, floating cubes, glowing grids, holographic brains and robots say nothing about the product. +- A landing hero gets a visual that shows something true. +- Dashboards and app screens almost never need generated images. Their moments come from the product itself: a chart drawn with care, figures set beautifully, real content like product photos or avatars, a status that feels alive. A physical thing the product deals with, like a payment card in a finance app, is usually better built in code with real materials, a soft gradient, a chip, a number. +- Generate images for an app only for empty states, onboarding and marketing moments, or when the user asks for it. +- With an image generation tool, the first choice for a landing hero is a scene from the world in the direction, generated wide enough to sit behind the whole hero, with calm empty space where the text goes. It carries the color and the mood, and the chrome stays quiet over it. +- Under the scene, or as the visual when there is no tool, show the product. Build a preview in code of what the page already promises, with the project's components or careful HTML and plausible data, like the main screen, one key card or the result the product produces. Showing what the copy already claims is not inventing a feature. In a scene hero it rises from the bottom edge and runs past it. +- The product view carries a moment, not just a plain card. A rendered object inside it, like a payment card on a figure, a rich chart or a small object from the world. These are kinds of objects, so using one that fits is fine. +- Before this pass, look at the references in [references/refs.md](references/refs.md). Take qualities from them, like composition, light, restraint and how the moment is placed, never their subject or layout. +- Rendered objects and scenes come from an image generation tool when you have one. If you do not, build what you can in code, write the prompts for the rest and leave empty slots of the right size. +- How to pick the subject, the prompt recipe and how to place the image are in [references/visuals.md](references/visuals.md). Read it before this pass. + +## 9. Motion + +- Remove motion that loops for no reason, like `animate-pulse` or `animate-ping` on things that are not live, shimmer on headings, breathing glows, floating elements and parallax. +- Remove `transition-all`, bouncy springs, `hover:scale` on cards and anything that moves more than a few pixels on hover. +- Keep motion that explains something. A hover is a quick color or surface change, a press is a tiny scale down, a menu fades and rises a few pixels from where it opens, a changed number tweens. +- Transition color, background, border, opacity and transform, and name them, like `transition-colors` or `transition-[transform,opacity]` in Tailwind, instead of a catch all. Never animate width, height, top, left, margins or large blurs, they stutter on weak devices. +- One easing curve and a few durations across the screen, from the tokens. Short for feedback, a little longer for things that enter. +- At most one entrance on load, a short fade and rise for the main content with a small stagger. Not every card flying in from a different side. + +## Story video + +A short video where the screen unslops itself step by step, with red redlines on the slop. Look at [refs/story-dashboard-frames.webp](references/refs/story-dashboard-frames.webp) before you build it, eight frames from it in order. That video went viral, and this is how it works: + +- **Nothing is swapped, everything morphs.** One page, and each step only changes CSS, so padding, gaps, sizes, colors, radii and shadows tween from the old value to the new one in place. You see the spacing open up, the purple drain out, the corners soften. +- **A wave.** Elements start a few dozen milliseconds apart from the top left down, so a change runs across the screen instead of everything flipping at once. +- **Text rolls.** Copy that changes blurs out, and the new words rise in letter by letter. +- **Redlines first.** The slop gets red selection boxes with tags, a spacing step shows red measurements that turn white once fixed, a details step outlines corners with their radius. +- **Close and short.** Square, cropped close on the part that changes most, so it reads on a phone, and about 10 seconds. + +It adds a few minutes, so make it only when the user wants it. + +1. **One story file.** Build `unslop-story/story.html`, a standalone copy of the original screen with its styles in the file. Keep the original rules as they are, they are step 0. +2. **Steps.** Three to five, merging passes so each one changes something you can see, like `Spacing`, `Color and icons`, `Type and copy`, `Details`. A new chart or visual goes into the last step. List them in the head as ``. A name containing Spacing shows spacing measurements, one containing Details shows corner outlines. +3. **Each step is CSS on top.** Write the changes of step 1 under `.s1`, step 2 under `.s2` and so on, like `.s1 .card { padding: 24px }` or `.s2 body { background-color: #141415 }`. The classes add up, so step 3 still has the rules of steps 1 and 2. Change values, never structure, so the browser can tween them. +4. **What cannot tween, and how to handle it.** + - Gradients, glows and washes: put them on their own layer and fade its `opacity` to 0, with the solid color underneath changing. Gradient text goes on the `.from` span only, never on the swap or its parent, because a text clip also catches the hidden new text. + - Pills: write their radius as half the height, like `23px`, not `9999px`, or the corner only changes in the last frame. + - Use swaps sparingly, only where the words change, at most eight in a story. Twenty swaps blurring at once read as a cut between two pictures, which is exactly what the video must not look like. Weight, size, tracking and color changes tween on their own, so they never need a swap. A new font family switches directly in its step, without swaps. + - Text that changes, a new font family, an emoji that becomes an icon: `Welcome back, Damir! ๐Ÿ‘‹October 2026`. At step 3 the old part blurs out and the new one rolls in. + - Emoji that become icons: put the new icon next to the emoji inside the same box, both absolutely stacked, and crossfade their `opacity` in the step. That is not a swap and does not count toward the limit. + - Things that disappear, like a `New โœจ` badge: `data-out="2"` collapses them at step 2. + - Things that appear, like a legend: `data-in="4"` fades them in at step 4. Reserve their space so nothing jumps. +5. **Redlines.** The measurements follow the elements live, so their numbers count from the old value to the new one while things move. That is the moment people watch, so choose them well. + - `data-gap` goes on the blocks of a vertical stack, like the brand row, the search, the navigation group and the plans group in a sidebar, or the headline, the stat row and the chart in the main column. Not on every row, or the screen fills with tiny 2px tags. Aim for four to six measured gaps per stack, and give each stack its own name if there is more than one, like `data-gap="sidebar"` and `data-gap="main"`. + - `data-radius` goes on two to four buttons and cards whose corners change. + - `data-slop="Gradient text"` and so on marks the slop for a short first beat, at most five, on the clearest examples that sit apart from each other, with short tags. It is optional, the reference video has none. +6. **Only the screen and its background.** The video shows the story page as it is, with no extra window, border or shadow around it, so the page's own background fills the frame and the screen is the only box. If the screen sits in a window that grows to desktop proportions, anchor it to the left in the story so it widens to the right instead of gliding sideways. The story page renders 1440 wide and at least 900 tall, so do not size things from `100vh`, and keep `position: fixed` for background layers only. +7. **Camera.** `` sets which part of the page fills the frame, as x, y and width in page pixels. Point x and y at the screen's own top left corner, so the frame starts with the page background around it, and crop close on where the most visible change happens, usually the sidebar or headline and the first cards. +8. **Render.** `node /scripts/story.mjs unslop-story/story.html unslop-story.mp4`, square by default, `--wide` for 16:9. It needs Playwright and ffmpeg, so if either is missing, tell the user the one command that installs it. To watch it live first, run it with `--play` instead of the output path, and it loops in a browser window until closed. Pull a few frames with ffmpeg and look at them before you hand it over. Checking the story file and the video does not count toward the render limit of a run. +9. **The story file is a presentation piece.** The clean result still ships as normal code in the project's own stack, without `.s` classes or story attributes. Every video carries a small `unslop by @heydamir` credit, as the license asks. + +## Do not + +- Do not rename the product, even when the name has AI in it. Keep a real logo as it is. When the logo itself is slop, like an emoji or a sparkle in a gradient tile, redraw it flat: the wordmark alone in the new type, or a simple glyph from the product's idea in one solid color. Never a glossy or gradient ball, a glow or a gradient tile, those read as AI at first sight. +- Do not change what the product is. Keep its features, content and the order of sections unless the user asks for more. Placeholder content that means nothing may be replaced with content that does. +- Do not replace removed decoration with new decoration, and do not swap one trend for another like glass, grain, noise, neon or bento grids. +- Do not invent features, claims or data. +- Do not fall back to the same look for every product. The direction decides the palette tint, type and visual, not habit. + +## Checklist + +- [ ] The direction is written and every pass follows it +- [ ] Every spacing value is on the 4px scale and equal things have equal sizes +- [ ] Icons, labels and numbers each sit in one aligned column +- [ ] One tinted neutral ramp, three text roles, one accent with enough contrast +- [ ] No hue gradients, gradient text, glows, colored shadows or color blobs +- [ ] No emoji left in the interface +- [ ] One icon set at one size and stroke +- [ ] Type chosen for the character, weights 400 to 600, no uppercase tracked labels, no italic, no negative tracking on small text +- [ ] No exclamation marks, no trailing periods on short strings, no greeting headline, no unverified claims +- [ ] Numbers in one format with tabular figures +- [ ] Nested radii add up +- [ ] A hero has a real visual or a product preview with at least one moment, never filler and never a labeled placeholder +- [ ] The first screen is composed as a whole with no dead empty band +- [ ] No looping or bouncy motion, one easing curve, no animated layout properties +- [ ] Template check. If you swapped the product name for another product's, would the screen still fit? If yes, push the direction harder in the copy, the visual and the signature detail diff --git a/.opencode/skills/unslop/agents/openai.yaml b/.opencode/skills/unslop/agents/openai.yaml new file mode 100644 index 0000000..cfecf69 --- /dev/null +++ b/.opencode/skills/unslop/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Unslop" + short_description: "Turn vibecoded, AI-looking UI into a designed interface" + default_prompt: "Use $unslop on this screen so it stops looking AI-generated." diff --git a/.opencode/skills/unslop/references/effects.md b/.opencode/skills/unslop/references/effects.md new file mode 100644 index 0000000..621f8ec --- /dev/null +++ b/.opencode/skills/unslop/references/effects.md @@ -0,0 +1,113 @@ +# Effects + +Code treatments that turn an image into something made for this page. They are optional. Use one only when it fits the character in the direction, on the main visual only, and never as decoration on other elements. + +## Glyphs + +Draw the image as a grid of characters, brightness picking the character. It works best when the characters come from the product's own material, so the image is literally made of what the product deals with. + +- Money, invoices, finance: `. : 1 7 4 0 8 $ %` +- Code, developer tools: `. : / { } < > = #` +- Music: note names like `c d e g a` and `โ™ญ โ™ฏ` +- Writing, reading: lowercase letters of a word from the product +- Data, monitoring: `. ยท : | + โ–ช` + +Keep the cell between 6 and 10px, use the mono from the type choice, and color the glyphs from the image or from the ramp. Let a few cells change slowly over time so the picture feels alive. + +### Implementation + +```js +function glyphs(canvas, image, { cell = 8, chars = ". :1740$%", font = "Geist Mono", fps = 8 } = {}) { + const ctx = canvas.getContext("2d"); + const columns = Math.floor(canvas.width / cell); + const rows = Math.floor(canvas.height / cell); + + const sample = document.createElement("canvas"); + sample.width = columns; + sample.height = rows; + const sampler = sample.getContext("2d", { willReadFrequently: true }); + sampler.drawImage(image, 0, 0, columns, rows); + const pixels = sampler.getImageData(0, 0, columns, rows).data; + + const atlas = document.createElement("canvas"); + atlas.width = cell * chars.length; + atlas.height = cell; + const pen = atlas.getContext("2d"); + pen.font = `500 ${cell + 1}px ${font}`; + pen.textAlign = "center"; + pen.textBaseline = "middle"; + pen.fillStyle = "#fff"; + [...chars].forEach((char, index) => pen.fillText(char, index * cell + cell / 2, cell / 2)); + + const cells = []; + for (let y = 0; y < rows; y++) { + for (let x = 0; x < columns; x++) { + const i = (y * columns + x) * 4; + const light = (0.2126 * pixels[i] + 0.7152 * pixels[i + 1] + 0.0722 * pixels[i + 2]) / 255; + cells.push({ x, y, light, color: `rgb(${pixels[i]},${pixels[i + 1]},${pixels[i + 2]})`, seed: Math.random() }); + } + } + + const tinted = document.createElement("canvas"); + tinted.width = cell; + tinted.height = cell; + const tint = tinted.getContext("2d"); + + const draw = (step) => { + ctx.clearRect(0, 0, canvas.width, canvas.height); + for (const c of cells) { + if (c.light < 0.06) continue; + const jitter = Math.sin(c.seed * 999 + step) > 0.92 ? 1 : 0; + const pick = Math.min(chars.length - 1, Math.floor(c.light * chars.length) + jitter); + tint.globalCompositeOperation = "copy"; + tint.drawImage(atlas, pick * cell, 0, cell, cell, 0, 0, cell, cell); + tint.globalCompositeOperation = "source-in"; + tint.fillStyle = c.color; + tint.fillRect(0, 0, cell, cell); + ctx.drawImage(tinted, c.x * cell, c.y * cell); + } + }; + + let frame = 0; + let last = 0; + let step = 0; + let visible = true; + new IntersectionObserver(([entry]) => (visible = entry.isIntersecting)).observe(canvas); + const loop = (now) => { + frame = requestAnimationFrame(loop); + if (!visible || now - last < 1000 / fps) return; + last = now; + draw(step++); + }; + frame = requestAnimationFrame(loop); + return () => cancelAnimationFrame(frame); +} +``` + +For a large canvas, bake the tinted glyphs for each cell once into an offscreen canvas and only redraw the cells that change on a step. + +## Halftone dots + +Dots on a grid whose size follows brightness. Suits editorial, print and crafted characters. Same sampling as glyphs, but draw `arc` circles with a radius of `light * cell / 2` in one ink color on the page color. + +## Dithering + +An ordered or error diffusion dither into two or three colors from the ramp. Suits retro, technical and playful characters. Do it once when the image loads, not on every frame. + +## Duotone + +Map the image's brightness onto two colors, the darkest surface of the ramp and the accent or the primary text color. The simplest way to make any generated image belong to the palette. In CSS, a grayscale image under a `mix-blend-mode: color` layer of the accent, or an SVG `feColorMatrix` filter. + +## Performance + +These effects broke on weak laptops before, so keep to these rules. + +- Read pixels once when the image loads. Never call `getImageData` inside the animation loop, it stalls the GPU and drops frames to single digits. +- Prepare glyph atlases and tinted cells ahead of time and draw them with `drawImage`. +- Animate at 8 to 12 steps per second, not 60, and change only a small share of cells per step. +- Pause when the canvas is off screen. +- Size the canvas for the device pixel ratio, but cap it at 2. + +--- + +Part of unslop by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), CC BY 4.0. Keep this credit in copies and adaptations. diff --git a/.opencode/skills/unslop/references/refs.md b/.opencode/skills/unslop/references/refs.md new file mode 100644 index 0000000..e6c23aa --- /dev/null +++ b/.opencode/skills/unslop/references/refs.md @@ -0,0 +1,17 @@ +# References + +Finished screens by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)). Look at them before the Visuals pass. Take their qualities, never their subject, palette or layout, so every product still gets its own screen. `unslop-dashboard-after` is the finished version of `examples/dashboard.html`, so when you unslop that example, find your own answer instead of rebuilding it. + +| File | What it shows | Why it works | +| --- | --- | --- | +| [unslop-hero-after.webp](refs/unslop-hero-after.webp) | An invoicing hero with a paper plane drawn in digits and currency signs, small notes pinned to it, and the product rising from below | The visual is made of the product's own material, money, so it is the moment and the signature at once. Text sits on calm sky, the chrome is quiet, and the product view invites a scroll | +| [unslop-dashboard-after.webp](refs/unslop-dashboard-after.webp) | A finance dashboard with two rendered payment cards and a revenue chart drawn as soft colored curves over a mirrored grey comparison | Quiet graphite chrome, and all the color lives in two moments, a real looking object and a chart where every color means a series | +| [beacon-engraving.webp](refs/beacon-engraving.webp) | An incident monitoring hero drawn as one blue engraving of a lighthouse on a rocky coast, with a serif headline in the same ink | A print style working because the whole page is in it. One ink, one paper tone, and the headline sits in the calm sky the drawing leaves for it | +| [meadow-3d.webp](refs/meadow-3d.webp) | A footer as a soft 3D meadow with a few matte crystal shapes and a huge wordmark standing behind the hills | Friendly stylized 3D with a restrained palette, type that becomes part of the scene, and quiet links on the open sky | +| [story-dashboard-frames.webp](refs/story-dashboard-frames.webp) | Eight frames from the viral story video, a vibecoded dashboard unslopping itself in about 10 seconds | Everything morphs in place in a wave, red redlines measure the slop and turn white when fixed, copy rolls letter by letter, and the frame is square and cropped close on the sidebar and first cards | + +What they share: one bright element per screen, quiet chrome around it, a visual that can be explained in one sentence, and headlines placed on space that was left empty for them. + +--- + +Part of unslop by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), CC BY 4.0. Keep this credit in copies and adaptations. diff --git a/.opencode/skills/unslop/references/refs/beacon-engraving.webp b/.opencode/skills/unslop/references/refs/beacon-engraving.webp new file mode 100644 index 0000000..cf8af41 Binary files /dev/null and b/.opencode/skills/unslop/references/refs/beacon-engraving.webp differ diff --git a/.opencode/skills/unslop/references/refs/meadow-3d.webp b/.opencode/skills/unslop/references/refs/meadow-3d.webp new file mode 100644 index 0000000..d8284c8 Binary files /dev/null and b/.opencode/skills/unslop/references/refs/meadow-3d.webp differ diff --git a/.opencode/skills/unslop/references/refs/story-dashboard-frames.webp b/.opencode/skills/unslop/references/refs/story-dashboard-frames.webp new file mode 100644 index 0000000..b9a0872 Binary files /dev/null and b/.opencode/skills/unslop/references/refs/story-dashboard-frames.webp differ diff --git a/.opencode/skills/unslop/references/refs/unslop-dashboard-after.webp b/.opencode/skills/unslop/references/refs/unslop-dashboard-after.webp new file mode 100644 index 0000000..9836639 Binary files /dev/null and b/.opencode/skills/unslop/references/refs/unslop-dashboard-after.webp differ diff --git a/.opencode/skills/unslop/references/refs/unslop-hero-after.webp b/.opencode/skills/unslop/references/refs/unslop-hero-after.webp new file mode 100644 index 0000000..ad90593 Binary files /dev/null and b/.opencode/skills/unslop/references/refs/unslop-hero-after.webp differ diff --git a/.opencode/skills/unslop/references/signs.md b/.opencode/skills/unslop/references/signs.md new file mode 100644 index 0000000..e9bae03 --- /dev/null +++ b/.opencode/skills/unslop/references/signs.md @@ -0,0 +1,103 @@ +# Signs of slop + +Go through every group and note each sign you find with where it lives. Each line pairs the sign with its fix. + +## Surfaces and color + +- Purple to blue or violet to pink gradients on backgrounds, cards, buttons or the active nav item โ†’ one neutral surface, accent only where it means something +- Gradient text in headings, numbers or the logo โ†’ solid primary text color +- Colored glows, like `shadow-[0_0_20px_rgba(139,92,246,0.5)]`, `drop-shadow` on emoji or glowing pills โ†’ remove, depth from surface steps and hairlines +- Radial color washes or blurred blobs behind content โ†’ remove +- Neon or high contrast colored borders โ†’ hairline at 6 to 10% opacity +- Many near identical tints of the same hue for text, like five lavenders โ†’ three text roles from one neutral ramp +- Every element highlighted, so nothing stands out โ†’ one accent used once or twice per screen +- Glass cards with a white inner highlight and a colored shadow โ†’ plain raised surface with a hairline + +## Type + +- Headings at weight 800 or 900 โ†’ 400 to 500 and size for hierarchy +- Uppercase labels with wide tracking, like `NAVIGATION` or `PAID THIS MONTH` โ†’ sentence case at normal or slightly open tracking +- Too many sizes and weights on one screen โ†’ a few sizes from the scale, weights 400 and 500 +- Numbers in mixed formats, like `$48,210` next to `$124,860.40`, or `07` โ†’ one format, tabular figures +- Negative letter spacing on small text โ†’ zero or slightly positive +- Italic used for emphasis โ†’ color or weight instead + +## Icons + +- Emoji as nav icons, label decoration or status, like ๐Ÿ  ๐Ÿ“ฅ ๐Ÿš€ ๐Ÿ’ฐ โš ๏ธ ๐Ÿ“ˆ ๐Ÿ”ฅ ๐Ÿ˜ฌ โ†’ one line icon set or nothing +- Emoji inside pills and counts, like `18 ๐Ÿ‘ค` โ†’ plain number +- Icons in many sizes and colors โ†’ one size, one stroke, text role color +- Icons nudged off the column โ†’ same icon box width in every row +- A sparkle โœจ for anything AI โ†’ remove or use a plain icon only where it explains something + +## Spacing + +- Arbitrary values, like `mt-[13px]`, `ml-[7px]`, `h-[31px]` โ†’ steps on the 4px scale +- Rows in one list with different heights or gaps โ†’ one height, one gap +- Groups that sit closer to their neighbours than to their own items โ†’ smaller gaps inside groups, bigger between them +- Uneven padding inside sibling cards โ†’ one padding +- Big empty gaps above a section and cramped space below it โ†’ balanced rhythm + +## Copy + +- Greeting headlines, like `Welcome back, Damir! ๐Ÿ‘‹` โ†’ the page or period as the title +- Exclamation marks and `!!` โ†’ plain statement +- Changes without a reference, like `+12%!!` or `3 less!!` โ†’ `+12% vs last month`, `3 fewer vs last month` +- Wordy placeholders, like `Search anything...` โ†’ `Search` +- Hype words, like supercharge, seamless, unlock, magic, revolutionize โ†’ what the thing actually does +- Trailing periods on labels and buttons โ†’ none +- Fake looking data, like `$123,456` or `[X]` โ†’ plausible numbers + +## Components + +- Glowing pills for counts, plans, shortcuts or status โ†’ quiet mono text or a keycap +- `PRO`, `New โœจ`, `Beta ๐Ÿš€` badges everywhere โ†’ remove or one quiet text badge +- Every element a full pill, from search to buttons to cards โ†’ radius by size, nested radius math +- Charts with rainbow bars, glowing bars, pill shaped bars or uppercase day labels โ†’ one or two series, muted grid and mono labels, one highlight color +- Every chart or stack in several shades of the accent, so nothing leads โ†’ neutral steps with the accent only on the one thing that matters +- Sparklines, tallies or mini bars added to every stat card โ†’ the number, its label and its change +- Equal cards side by side, each with the same chart or layout โ†’ one main chart, compact stats, varied widths +- Logo as a gradient tile with a sparkle and a gradient wordmark โ†’ product name as text plus a simple flat mark +- Logo mark as a glossy orb, a gradient ball or a glowing dot โ†’ a flat glyph in one solid color, or the wordmark alone + +## Landing pages and heroes + +- A pill badge above the headline, like `โœจ Introducing X 2.0` โ†’ remove, or a quiet text link if it is real news +- A headline that could fit any product, like `Supercharge your workflow with AI` โ†’ say what the product does for whom +- Gradient or shimmer on the headline โ†’ solid text +- Floating blobs, orbs, grids and glowing lines behind the hero โ†’ plain background or one real product visual +- Everything stacked and centered, headline, text, buttons and a picture below โ†’ an asymmetric split or text over a large product view, see the Layout pass +- Three or more buttons, or chips and avatars stacked under the buttons โ†’ one primary and at most one secondary action +- `Trusted by 10,000+ teams`, `Loved by 10,000+`, rows of avatar dots or five stars without a source โ†’ ask if it is real, then make it quiet and specific or remove it +- Parallax and elements floating on scroll โ†’ static layout, motion only with a purpose +- Feature cards with an emoji, a bold title and a vague line each โ†’ fewer features, concrete copy, line icons or none + +## Visuals + +- Glossy 3D blobs, orbs, liquid chrome shapes or a purple nebula as the hero visual โ†’ the product itself or one concrete metaphor for what it does +- Stock robots, glowing brains, circuit lines or holograms for anything AI โ†’ remove, or show what the AI actually produces +- Text, fake buttons or a fake logo baked into an image โ†’ real text in the page, a clean image without text +- An image that does not blend into the page, with a hard edge and a different black โ†’ background matched to the page and a soft mask at the edges +- A visual on every section โ†’ one visual where it helps, usually only the hero + +## Motion + +- `animate-pulse`, `animate-ping` or breathing glows on things that are not live โ†’ static +- `transition-all` and `hover:scale` on cards and buttons โ†’ color or surface step on hover, a tiny scale down on press +- Bouncy springs and overshoot on simple UI โ†’ one ease-out curve +- Everything flying in on load from different directions โ†’ one short fade and rise with a small stagger +- Parallax, floating loops and elements that move on scroll โ†’ static layout +- Animating width, height, top or left โ†’ transform and opacity + +## Template look + +These are not slop, but they are what a cleaned up screen falls back to when nobody made a decision. Watch for them after the passes. + +- Pure grey #161616 page, Inter, white text and the old brand color on one button, on a product whose world is warm or bright โ†’ a ramp tinted toward the world and an accent from it +- A headline that would fit another product if you swapped the name โ†’ say what this product does, with its own nouns +- A hero visual that is an empty box or a generic picture โ†’ a product preview in code or an image from the world +- Every product getting the same layout, type and palette โ†’ let the direction choose + +--- + +Part of unslop by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), CC BY 4.0. Keep this credit in copies and adaptations. diff --git a/.opencode/skills/unslop/references/tokens.md b/.opencode/skills/unslop/references/tokens.md new file mode 100644 index 0000000..431cdc3 --- /dev/null +++ b/.opencode/skills/unslop/references/tokens.md @@ -0,0 +1,135 @@ +# Tokens + +Starting values for each pass. When the project already has a good system, keep its values and only fix how they are used. These are starting points, not a house style, so let the direction move them. + +## Spacing scale + +4, 8, 12, 16, 20, 24, 32, 40, 48, 64, 80, 96, 128 + +- Inside a control: 4 to 12 +- Between related items, like rows in a list: 2 to 8 +- Between groups, like a label and its list or two cards: 12 to 24 +- Between sections: 32 to 64, landing sections 80 to 128 +- Row heights: 32 compact, 36 default, 40 to 44 touch +- Hero: 96 to 128 from the navigation to the headline on desktop, 48 to 64 on mobile + +## App layout + +- Sidebar 240 to 280 wide, content fluid with page padding 24 to 32, capped around 1440 to 1600. +- Top bar or page header 56 to 64 tall. +- Navigation rows 32 to 36, gap 2 to 4, section labels 24 above and 8 below. +- Stat cards in a row share one height, usually 3 or 4 compact cards per row on desktop, numbers only. +- The main chart spans at least two thirds of the content width and is 280 to 360 tall. A second data block next to it is a list or table, narrower, not another chart. + +## Neutral ramps + +For apps and dashboards use graphite, or a ramp just as grey, for every surface. For landing pages pick the ramp whose tint matches the world in the direction. Warm suits paper, wood, food, craft and anything human. Ink suits night, sky, water and anything technical. You can tint your own ramp the same way, keeping saturation very low, so a surface never reads as a color on its own. + +### Dark + +| Role | Graphite | Warm | Ink | +| --- | --- | --- | --- | +| Deepest surface, like a sidebar | #0f0f10 | #12110f | #07080d | +| Page background | #141415 | #161513 | #0b0d14 | +| Card | #19191b | #1c1a18 | #11141c | +| Raised, like an active row or input | #232325 | #262421 | #1a1e28 | +| Hairline | primary text at 8% | primary text at 8% | primary text at 8% | +| Primary text | #ededed | #efe9de | #eef0f5 | +| Secondary text | #a1a1a6 | #aaa398 | #a3a9b8 | +| Muted text and icons | #85858b | #8a847a | #7a8296 | + +### Light + +| Role | Graphite | Warm | Ink | +| --- | --- | --- | --- | +| Page background | #fafafa | #f7f4ee | #f5f7fa | +| Card | #ffffff | #fdfbf7 | #ffffff | +| Raised, like an active row or input | #f4f4f5 | #efe9df | #eceff4 | +| Hairline | primary text at 8% | primary text at 8% | primary text at 8% | +| Primary text | #171717 | #1f1b16 | #111827 | +| Secondary text | #525252 | #5e554a | #4b5565 | +| Muted text and icons | #737373 | #7b7266 | #6b7487 | + +Muted text is for meta and captions only and stays near 4.5 to 1 on its surface. Body text uses primary or secondary. + +Semantic colors, toned to sit inside any ramp: positive #4cc38a on dark and #16a34a on light, negative #e5484d on dark and #dc2626 on light. + +## Accent + +- Keep a real brand color. A violet to blue or any other hue to hue glow gradient is slop, not a brand color, unless the user says otherwise. +- Without a brand color, take it from the world. Warm worlds suit amber, coral or ember orange. Cool worlds suit cobalt, teal or a clear sky blue. Natural worlds suit moss or sage. Avoid the purple to blue family unless the brand really is purple. +- Text on the accent reaches 4.5 to 1 contrast. Darken the accent or use dark text when it does not. +- The accent covers a few percent of the screen, like the primary action, the selection and one key figure. + +## Buttons + +- Hero buttons 44 to 48 tall, navigation buttons 32 to 36, padding 16 to 20 on the sides. +- Primary on dark: an off white from the ramp with dark text, or the accent. Primary on light: the primary text color as a fill with white text, or the accent. +- Secondary: the raised surface with a hairline, primary text. +- A soft top highlight like `inset 0 1px 0 rgba(255,255,255,0.12)` on dark buttons gives depth without a glow. + +## Type families + +Pick by the character in the direction. All of these are free, and a partner can come from any row. + +| Character | Interface family | Optional partner | +| --- | --- | --- | +| Precise, technical, calm | Geist, Inter, IBM Plex Sans | Geist Mono, IBM Plex Mono, JetBrains Mono for figures and meta | +| Warm, friendly, human | Instrument Sans, DM Sans, Figtree | Instrument Serif or Newsreader for the hero headline only | +| Editorial, literary, crafted | Inter or Instrument Sans for the interface | Newsreader, Instrument Serif or Fraunces for headlines | +| Playful, bold | Space Grotesk, Bricolage Grotesque | a mono for small labels | + +A display serif belongs to large headlines only, never to labels or buttons. + +## Type scale + +| Use | Size | Line height | Weight | +| --- | --- | --- | --- | +| Small labels, meta, counts | 12 | 16 | 400, mono welcome | +| Secondary text, captions | 13 to 14 | 20 | 400 | +| Body, nav items | 14 to 15 | 20 to 22 | 400 to 500 | +| Logo wordmark | 16 to 18 | 1 | 500 to 600 | +| Hero subhead | 18 to 20 | 28 to 30 | 400, secondary color, at most 36rem wide | +| Card figures | 28 to 32 | 1 | 500, tabular | +| Page title | 28 to 36 | 1.15 | 400 to 500 | +| Hero headline | 56 to 72 desktop, 36 to 44 mobile | 1.02 to 1.08 | 400 to 500 | + +Letter spacing is 0 for anything under 16px, slightly positive like 0.01em is fine for small muted text. Display text from 40px can go to -0.02em, and from 56px to -0.03em. + +## Radius + +| Element | Radius | +| --- | --- | +| Keycap, tag, small button | 4 to 6 | +| Row, input, button | 8 to 10 | +| Card | 12 to 16 | +| Panel or window | 14 to 20 | + +Nested radius: inner = outer โˆ’ padding, never below 4. A 16px card with 8px padding holds items with an 8px radius. + +## Icons + +lucide, 16 or 18px, stroke 1.5, color of the muted or secondary text role, 8 to 10px gap to the label. + +## Depth + +- Dark: surface steps plus a hairline. An active row can carry `inset 0 1px 0 rgba(255,255,255,0.12)` as a top highlight. +- Light: `0 1px 2px rgba(0,0,0,0.05)` for small things, `0 8px 24px rgba(0,0,0,0.06)` for floating ones. + +## Motion + +One easing curve for everything, `cubic-bezier(0.22, 1, 0.36, 1)`. + +| Use | Duration | +| --- | --- | +| Hover, press, toggles | 120 to 180ms | +| Menus, popovers, tooltips | 160 to 220ms | +| Entrances, sheets, layout changes | 300 to 500ms | +| Stagger between items | 40 to 60ms, at most six items | + +- Rise distance on enter: 6 to 12px. Press scale: 0.97 to 0.98. +- Transition color, background, border, opacity and transform only. + +--- + +Part of unslop by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), CC BY 4.0. Keep this credit in copies and adaptations. diff --git a/.opencode/skills/unslop/references/visuals.md b/.opencode/skills/unslop/references/visuals.md new file mode 100644 index 0000000..d7e89c1 --- /dev/null +++ b/.opencode/skills/unslop/references/visuals.md @@ -0,0 +1,84 @@ +# Visuals + +How to replace decorative filler with a visual that belongs to this product, and how to generate one when an image tool is available. + +## Decide what the visual is + +1. **Does the screen need one?** Dashboards, settings and most app screens do not. Landing heroes and empty states usually do. +2. **Is there an image tool?** For a landing hero, generate a scene from the world as the background of the whole hero, with the subject on one side and calm space for the headline on the other. Then show the product rising from the bottom edge. This is the strongest hero. +3. **Show the product.** Below the scene, or as the visual when there is no tool, build a preview in code of what the page already promises. Pick the one moment that proves the headline, not the whole app. A scheduling tool shows one week with a booked slot, a code review tool shows one diff with a comment, a habit app shows a streak on one card. Use the project's own components when they exist, otherwise careful HTML in the new palette, with plausible data and real looking names. Frame it like a window or a device only if that helps it read. +4. **Write the scene as one sentence.** Use the world and character from the direction and write the subject as one plain sentence before generating anything. If you cannot say it in one sentence, it is not clear yet. + +These examples only show the shape of a good sentence. Do not reuse them, derive yours from the product in front of you. + +- A tool that protects backups โ†’ a small lighthouse on a dark rocky coast with its beam reaching into fog +- A meditation app โ†’ a single cup of tea steaming on a windowsill at first light +- A logistics API โ†’ one container ship seen from straight above on a calm grey sea +- A language learning app โ†’ a stack of handwritten flashcards on a cafรฉ table in afternoon sun + +Avoid the AI defaults, unless the product is literally about them. No glossy blobs, orbs, nebulas, chrome liquid, floating cubes, neon circuits, holograms, glowing brains, robots, rockets or crystal shapes. + +## Styles that work + +Pick one that fits the character, and keep it for the whole page. + +- **Painted background art.** A calm sky or landscape with one subject, painted like the backgrounds of modern Japanese animated films, crisp clouds and soft light, with open sky left for the text. Good for warm, hopeful, human products. +- **Cinematic photo of one object.** A single real thing in simple dramatic light on a plain field, like a lighthouse in fog or a paper object in mid air. Good for calm, serious, trustworthy products. +- **Soft stylized 3D.** One small scene or object with matte materials and gentle light, like a doorway standing in a meadow. Good for playful or friendly products. +- **Product object render.** A real looking object from the product, like a payment card, a phone or a package, studio lit with true materials, used as a moment inside the interface. Good for anything that touches the physical world. +- **Light and optics.** One physical phenomenon on a dark field, like light splitting through a prism. Good for technical or data products. +- **Engraving or print.** A monochrome engraving or woodcut in one ink on a soft tinted page, with type and palette that belong to the same world. Good for products with heritage, craft or an editorial voice. +- **A code treatment over an image.** Rendering a generated image as glyphs, dots or a duotone in the page palette, so it feels made for the page. See [effects.md](effects.md). + +What tends to fail: + +- Prompts that ask for film grain, paper grain or a mottled texture. Models bake a fine checker pattern into the image. Add texture in code if you need it. +- A print style, like engraving, woodcut or risograph, on one image while the rest of the page is a plain modern interface. It reads as a filter. The same styles work well when the whole brand lives in them, with matching type and a paper palette, like a monochrome engraving on a soft tinted page. +- Several subjects or a busy collage. The idea stops reading in a second. +- Purple and blue glossy abstractions of any kind. + +## Objects for moments + +Besides whole scenes, generate single objects to place into the interface, like a payment card, a phone, a parcel, a coffee cup or a notebook from the product's world. They make a coded preview feel real. + +- Ask for one object, studio lit, on a plain background in the exact page color, with soft contact shadow and nothing else in frame. +- Ask for the angle you need in the layout, like "tilted 20 degrees, seen slightly from above". +- If the tool can remove backgrounds, cut the object out. Otherwise blend it with a mask into the page color. +- Place it overlapping the edge of a card or window, a little rotated, with a soft shadow in the ramp's darkest color, so it sits in the same space as the UI. +- Background removal deletes dark areas that are close to the background color, like a switched off black screen. Ask for the screen in a contrasting flat color, or rebuild the alpha from the outer edge of the cutout. +- To show real UI on a generated device, generate it straight on with no perspective, measure the screen rectangle, lay HTML over it and rotate both together. The text stays sharp and real. +- Everything inside one visual tells the same story. If the list shows invoice INV-0142 for a client, the phone shows the same client, number and amount. + +## Generate it + +Look for an image generation tool among the tools you have, for example an MCP server for fal, Replicate, OpenAI images, Higgsfield or a design tool. If there is one: + +- Show the user the prompt, the model and the size, and ask before running, since generation can cost money. +- Run once. Judge the result against the one sentence and the palette before trying again, and change the prompt rather than rerolling the same one. +- Save the result into the project's assets, not a temporary URL. + +If there is no tool and the screen needs a scene or an object, say so before the Visuals pass and offer to connect one, like an MCP server for fal, Replicate, OpenAI images or Higgsfield, then go on once it is connected. If the user skips it, leave the slot empty and put the prompt in your report, not on the page. The empty slot is a plain surface step of the exact size with the radius of the layout and no text inside, so nothing unfinished can ship by accident. Tell the user which kind of service can generate it and the file name to save it under. + +## Prompt recipe + +Build the prompt from these parts, in this order: + +- **Subject.** The one sentence, concrete and physical. +- **Setting.** Where it is, kept simple. +- **Light and palette.** Matched to the page. Name the page background color so the edges can blend into it, and the direction of the light. +- **Style.** One clear direction that fits the character, like cinematic photo, soft 3D render, editorial illustration, risograph print or painted background. Pick what fits the product, not what is trending. +- **Composition.** Where the subject sits so the text has room, like "subject on the right half, calm empty space on the left". For a scene behind the whole hero, ask for 16:9 or wider, keep the lower fifth calm too, since the product view rises over it, and fade the edges into the page color in code. +- **Exclusions.** No text, no letters, no logos, no UI, no watermark. +- **Format.** The aspect ratio of the slot at twice the display size. + +## Put it on the page + +- Export as WebP or AVIF at twice the display size, with a real `alt` that says what is shown. +- Match the image edges to the page background. If they do not match, fade them with a mask, like `mask-image: radial-gradient(closest-side, #000 70%, transparent)`, or a gradient overlay in the page color. A hard rectangle on a page of a different color is a sign of slop. +- Keep it static or give it one slow, subtle motion that fits the subject. No parallax. +- A treatment in code, like a duotone into the page palette, can make a generated image feel native. Use one only when it fits the character, and never as new decoration. +- Load the hero image with high priority and give it fixed dimensions so the layout does not jump. + +--- + +Part of unslop by Damir Sokolovsky ([@heydamir](https://x.com/heydamir)), CC BY 4.0. Keep this credit in copies and adaptations. diff --git a/.opencode/skills/unslop/scripts/snapshot.mjs b/.opencode/skills/unslop/scripts/snapshot.mjs new file mode 100644 index 0000000..f4af5ce --- /dev/null +++ b/.opencode/skills/unslop/scripts/snapshot.mjs @@ -0,0 +1,144 @@ +import { createRequire } from "node:module"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; + +const usage = "usage: node snapshot.mjs [pass label] [--dark | --light] [--width=1440] [--height=810]"; +const args = process.argv.slice(2); +const flag = (name) => args.find((arg) => arg.startsWith(`--${name}=`))?.split("=")[1]; +const [url, out, label] = args.filter((arg) => !arg.startsWith("--")); +const width = flag("width") ?? "1440"; +const height = flag("height") ?? "810"; +const scheme = args.includes("--dark") ? "dark" : args.includes("--light") ? "light" : "no-preference"; +if (!url || !out) { + console.error(usage); + process.exit(1); +} + +async function browserKit() { + for (const name of ["playwright", "playwright-core"]) { + try { + return await import(name); + } catch {} + try { + return createRequire(join(process.cwd(), "package.json"))(name); + } catch {} + } + console.error("Playwright is not installed. In your project, run `npm install -D playwright` followed by `npx playwright install chromium`."); + process.exit(1); +} + +const chrome = [process.env.CHROME_PATH, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((path) => path && existsSync(path)); +const { chromium } = await browserKit(); +const browser = await chromium.launch(chrome ? { executablePath: chrome } : {}); +try { + const page = await browser.newPage({ viewport: { width: Number(width), height: Number(height) }, colorScheme: scheme }); + await page.goto(url, { waitUntil: "networkidle" }); + await page.evaluate(() => document.fonts.ready); + await page.waitForTimeout(600); + const html = await page.evaluate(async ([label, scheme]) => { + const LIMIT = 3_000_000; + const absolute = (value, base = location.href) => { + try { + return new URL(value, base).href; + } catch { + return value; + } + }; + const inlined = new Map(); + const inline = async (href) => { + if (!href || href.startsWith("data:")) return href; + if (!inlined.has(href)) { + inlined.set( + href, + fetch(href) + .then((response) => (response.ok ? response.blob() : null)) + .then((blob) => + blob && blob.size < LIMIT + ? new Promise((done) => { + const reader = new FileReader(); + reader.onload = () => done(reader.result); + reader.readAsDataURL(blob); + }) + : href, + ) + .catch(() => href), + ); + } + return inlined.get(href); + }; + const urls = async (css, base) => { + const found = [...css.matchAll(/url\((['"]?)(?!data:)([^)'"]+)\1\)/g)]; + let result = css; + for (const [whole, , value] of found) { + if (/^(#|%23)/.test(value.trim())) continue; + const data = await inline(absolute(value.trim(), base)); + result = result.split(whole).join(`url("${data}")`); + } + return result; + }; + + const css = []; + for (const sheet of document.styleSheets) { + const owner = sheet.ownerNode; + let text = ""; + let base = location.href; + if (sheet.href) { + base = sheet.href; + text = await fetch(sheet.href).then((response) => (response.ok ? response.text() : "")).catch(() => ""); + } else if (owner && owner.textContent.trim()) { + text = owner.textContent; + } + if (!text) { + try { + text = [...sheet.cssRules].map((rule) => rule.cssText).join("\n"); + } catch {} + } + const media = sheet.media && sheet.media.mediaText; + css.push(media && media !== "all" ? `@media ${media} {\n${text}\n}` : text); + css[css.length - 1] = await urls(css[css.length - 1], base); + } + + const copy = document.documentElement.cloneNode(true); + copy.querySelectorAll("script, noscript, link[rel='stylesheet'], link[rel='preload'], link[rel='modulepreload'], style").forEach((node) => node.remove()); + copy.querySelectorAll("[srcset]").forEach((node) => node.removeAttribute("srcset")); + copy.querySelectorAll("source").forEach((node) => node.remove()); + copy.querySelectorAll("[loading]").forEach((node) => node.removeAttribute("loading")); + for (const node of copy.querySelectorAll("img[src], video[poster], image")) { + const attr = node.tagName === "VIDEO" ? "poster" : node.tagName.toLowerCase() === "image" ? "href" : "src"; + const value = node.getAttribute(attr); + if (value) node.setAttribute(attr, await inline(absolute(value))); + } + copy.querySelectorAll("a[href]").forEach((node) => node.setAttribute("href", absolute(node.getAttribute("href")))); + for (const node of copy.querySelectorAll("[style*='url(']")) { + node.setAttribute("style", await urls(node.getAttribute("style"), location.href)); + } + const style = document.createElement("style"); + let text = css.join("\n"); + if (scheme === "dark" || scheme === "light") { + const other = scheme === "dark" ? "light" : "dark"; + text = text + .replace(new RegExp(`prefers-color-scheme\\s*:\\s*${scheme}`, "g"), "min-width: 0px") + .replace(new RegExp(`prefers-color-scheme\\s*:\\s*${other}`, "g"), "max-width: 0px"); + const meta = document.createElement("meta"); + meta.name = "color-scheme"; + meta.content = scheme; + copy.querySelector("head").prepend(meta); + } + style.textContent = text; + copy.querySelector("head").appendChild(style); + if (label) { + const meta = document.createElement("meta"); + meta.name = "unslop-pass"; + meta.content = label; + copy.querySelector("head").prepend(meta); + } + return "\n" + copy.outerHTML; + }, [label ?? "", scheme]); + mkdirSync(dirname(resolve(out)), { recursive: true }); + writeFileSync(resolve(out), html); + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ path: resolve(out).replace(/\.html?$/, "") + ".live.png" }); + console.log(`saved ${resolve(out)}${label ? ` as ${label}` : ""}`); +} finally { + await browser.close(); +} diff --git a/.opencode/skills/unslop/scripts/story.mjs b/.opencode/skills/unslop/scripts/story.mjs new file mode 100644 index 0000000..dd6e826 --- /dev/null +++ b/.opencode/skills/unslop/scripts/story.mjs @@ -0,0 +1,461 @@ +import { createRequire } from "node:module"; +import { createServer } from "node:http"; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { dirname, extname, join, normalize, resolve } from "node:path"; + +const usage = "usage: node story.mjs [--wide], or node story.mjs --play to watch it live in a browser window"; +const args = process.argv.slice(2); +const [input, out] = args.filter((arg) => !arg.startsWith("--")); +const wide = args.includes("--wide"); +const play = args.includes("--play"); +if (!input || (!out && !play)) { + console.error(usage); + process.exit(1); +} + +async function browserKit() { + for (const name of ["playwright", "playwright-core"]) { + try { + return await import(name); + } catch {} + try { + return createRequire(join(process.cwd(), "package.json"))(name); + } catch {} + } + console.error("Playwright is not installed. In your project, run `npm install -D playwright` followed by `npx playwright install chromium`."); + process.exit(1); +} + +try { + execFileSync("ffmpeg", ["-version"], { stdio: "ignore" }); +} catch { + console.error("ffmpeg is not installed. Install it to render the video, for example `brew install ffmpeg`."); + process.exit(1); +} + +const file = resolve(input); +const root = dirname(file); +const html = readFileSync(file, "utf8"); +const meta = (name) => new RegExp(` step.trim()) + .filter(Boolean); +if (steps.length === 0) { + console.error('story.html needs listing the steps in order'); + process.exit(1); +} +const [frameX = 0, frameY = 0, frameWidth = 960] = (meta("unslop-frame") ?? "0 0 960").split(/\s+/).map(Number); +const kindOf = (text) => (/spac/i.test(text) ? "spacing" : /detail|radius|corner/i.test(text) ? "details" : null); + +const FRAME = wide ? { width: 1920, height: 1080 } : { width: 1080, height: 1080 }; +const PAGE_WIDTH = 1440; +const MARGIN = 72; +const VIEW = 0.75; +const scale = (FRAME.width - MARGIN) / frameWidth; +const offsetX = MARGIN / scale - frameX; +const offsetY = MARGIN / scale - frameY; +const padLeft = Math.max(0, offsetX); +const padTop = Math.max(0, offsetY); +const left = Math.min(0, offsetX) * scale; +const top = Math.min(0, offsetY) * scale; +const pageWidth = Math.max(PAGE_WIDTH + padLeft, Math.ceil((FRAME.width - left) / scale) + 2); +const padRight = pageWidth - PAGE_WIDTH - padLeft; +const pageHeight = Math.max(900 + padTop, Math.ceil((FRAME.height - top) / scale) + 2); + +const stage = ` + +
+
unslop by @heydamir
+ +`; + +const runtime = ` +(() => { + const red = "#ff4d6a"; + const spring = "cubic-bezier(0.3, 1.12, 0.45, 1)"; + const ease = "cubic-bezier(0.45, 0, 0.2, 1)"; + const layout = ["padding", "margin", "gap", "row-gap", "column-gap", "width", "height", "min-width", "min-height", "max-width", "max-height", "top", "right", "bottom", "left", "inset", "font-size", "line-height", "letter-spacing", "font-weight", "transform", "translate", "scale", "rotate", "border-width", "outline-width", "flex-basis", "grid-template-columns", "grid-template-rows"]; + const tint = ["color", "background-color", "border-color", "outline-color", "fill", "stroke", "stroke-dashoffset", "stroke-width", "box-shadow", "text-shadow", "border-radius", "opacity", "filter", "backdrop-filter", "clip-path", "text-decoration-color", "-webkit-text-fill-color"]; + const transition = [...layout.map((p) => p + " 700ms " + spring + " var(--wave, 0ms)"), ...tint.map((p) => p + " 900ms " + ease + " var(--wave, 0ms)")].join(", "); + const rise = "cubic-bezier(0.17, 0.84, 0.44, 1)"; + const steps = ${JSON.stringify(steps)}; + let css = \` + :root { interpolate-size: allow-keywords; } + html.story-live *, html.story-live *::before, html.story-live *::after { transition: \${transition} !important; } + .swap { display: inline-grid; justify-items: start; align-items: start; vertical-align: top; overflow: visible; } + div.swap, p.swap, h1.swap, h2.swap, h3.swap, li.swap { display: grid; } + .swap > .from, .swap > .to { grid-area: 1 / 1; } + .swap > .to { opacity: 0; pointer-events: none; } + .swap > .to .ch { display: inline-block; opacity: 0; transform: translateY(40%); filter: blur(8px); } + html.story-live .swap > .to .ch { transition: opacity 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms), transform 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms), filter 700ms \${rise} calc(var(--wave, 0ms) + 140ms + var(--ci) * 16ms) !important; } + [data-in] { opacity: 0; transform: translateY(10px) scale(0.98); } + \`; + steps.forEach((_, index) => { + const k = index + 1; + css += \` + html.s\${k} .swap[data-at="\${k}"] > .from { opacity: 0; filter: blur(10px); transform: translateY(-14px); } + html.s\${k} .swap[data-at="\${k}"] > .to { opacity: 1; } + html.s\${k} .swap[data-at="\${k}"] > .to .ch { opacity: 1; transform: none; filter: none; } + html.s\${k} [data-in="\${k}"] { opacity: 1; transform: none; } + html.s\${k} [data-out="\${k}"] { opacity: 0; max-height: 0 !important; max-width: 0 !important; margin-top: 0 !important; margin-bottom: 0 !important; padding: 0 !important; border-width: 0 !important; } + \`; + }); + + function letters(node) { + let index = 0; + const walk = (el) => { + [...el.childNodes].forEach((child) => { + if (child.nodeType === 3) { + const frag = document.createDocumentFragment(); + child.textContent.split(/(\\s+)/).forEach((part) => { + if (!part) return; + if (/^\\s+$/.test(part)) return frag.appendChild(document.createTextNode(part)); + const word = document.createElement("span"); + word.style.display = "inline-block"; + word.style.whiteSpace = "nowrap"; + [...part].forEach((char) => { + const span = document.createElement("span"); + span.className = "ch"; + span.style.setProperty("--ci", index++); + span.textContent = char; + word.appendChild(span); + }); + frag.appendChild(word); + }); + child.replaceWith(frag); + } else if (child.nodeType === 1 && child.tagName.toLowerCase() !== "svg") { + walk(child); + } + }); + }; + walk(node); + } + + const conf = window.parent.__stage.conf; + function layer() { + return window.parent.document.getElementById("story-layer"); + } + + const box = (el) => el.getBoundingClientRect(); + const sx = (x) => conf.x + x * conf.scale; + const sy = (y) => conf.y + y * conf.scale; + function put(className, style, html) { + const node = window.parent.document.createElement("div"); + node.className = className; + Object.assign(node.style, style); + if (html) node.innerHTML = html; + layer().appendChild(node); + return node; + } + const rect = (left, top, width, height) => ({ left: sx(left) + "px", top: sy(top) + "px", width: width * conf.scale + "px", height: height * conf.scale + "px" }); + + function audit() { + document.querySelectorAll("[data-slop]").forEach((el, index) => { + const b = box(el); + if (b.width < 4 || b.bottom < 0 || b.top > innerHeight) return; + const pad = 8 / conf.scale; + const wrap = put("s-fade", { ...rect(b.left - pad, b.top - pad, b.width + pad * 2, b.height + pad * 2), transitionDelay: index * 70 + "ms" }); + wrap.innerHTML = '
' + + [["left", "top"], ["right", "top"], ["left", "bottom"], ["right", "bottom"]].map(([x, y]) => '').join("") + + '' + el.dataset.slop + ""; + }); + } + + function spacing() { + const groups = new Map(); + document.querySelectorAll("[data-gap]").forEach((el) => { + const key = el.dataset.gap || "main"; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(el); + }); + const stageWidth = window.parent.innerWidth; + const several = groups.size > 1; + const pairs = []; + groups.forEach((list) => { + const items = list.filter((el) => box(el).height > 0 && box(el).top < innerHeight).sort((a, b) => box(a).top - box(b).top); + for (let i = 1; i < items.length; i++) { + if (box(items[i]).top - box(items[i - 1]).bottom <= 2) continue; + pairs.push({ group: items, above: items[i - 1], below: items[i], nodes: { + fill: put("s-fill s-x", {}), top: put("s-hair s-x", {}), bottom: put("s-hair s-x", {}), + ruler: put("s-ink s-y", {}), capTop: put("s-ink s-x", {}), capBottom: put("s-ink s-x", {}), + tag: put("s-tag s-fade", {}), + } }); + } + }); + return () => { + for (const { group, above, below, nodes } of pairs) { + const a = box(above); + const b = box(below); + const left = sx(Math.min(...group.map((el) => box(el).left))); + const ruler = Math.max(14, left - 20); + const top = sy(a.bottom); + const size = Math.max(0, (b.top - a.bottom) * conf.scale); + const value = Math.round(b.top - a.bottom); + const right = several ? sx(Math.max(...group.map((el) => box(el).right))) + 24 : stageWidth; + const start = several ? ruler - 8 : 0; + const band = { left: start + "px", width: right - start + "px", transformOrigin: ruler - start + "px 50%" }; + Object.assign(nodes.fill.style, band, { top: top + "px", height: size + "px" }); + Object.assign(nodes.top.style, band, { top: top + "px", height: "1px" }); + Object.assign(nodes.bottom.style, band, { top: top + size - 1 + "px", height: "1px" }); + Object.assign(nodes.ruler.style, { left: ruler + "px", top: top + "px", width: "1.5px", height: size + "px", transformOrigin: "50% 0" }); + Object.assign(nodes.capTop.style, { left: ruler - 4 + "px", top: top + "px", width: "9px", height: "1.5px" }); + Object.assign(nodes.capBottom.style, { left: ruler - 4 + "px", top: top + size - 1.5 + "px", width: "9px", height: "1.5px" }); + const room = ruler - 10 > 64; + Object.assign(nodes.tag.style, { left: (room ? ruler - 10 : ruler + 12) + "px", top: top + size / 2 - 15 + "px", translate: room ? "-100% 0" : "0 0" }); + const text = value + "px"; + if (nodes.tag.textContent !== text) nodes.tag.textContent = text; + const hidden = value < 4 ? "0" : ""; + Object.values(nodes).forEach((node) => { + if (node.style.visibility !== (hidden ? "hidden" : "")) node.style.visibility = hidden ? "hidden" : ""; + }); + } + }; + } + + function details() { + const items = [...document.querySelectorAll("[data-radius]")].filter((el) => box(el).bottom > 0 && box(el).top < innerHeight).map((el) => { + const wrap = put("s-fade", {}, '
'); + return { el, wrap, outline: wrap.firstChild, tag: put("s-tag s-fade", {}) }; + }); + return () => { + for (const { el, wrap, outline, tag } of items) { + const b = box(el); + const radius = Math.round(Math.min(parseFloat(getComputedStyle(el).borderTopLeftRadius) || 0, b.width / 2, b.height / 2)); + const pad = 5 / conf.scale; + Object.assign(wrap.style, rect(b.left - pad, b.top - pad, b.width + pad * 2, b.height + pad * 2)); + outline.style.borderRadius = radius * conf.scale + 5 + "px"; + Object.assign(tag.style, { left: sx(b.left) + "px", top: sy(b.bottom) + 12 + "px" }); + const text = "r " + radius; + if (tag.textContent !== text) tag.textContent = text; + } + }; + } + + const kinds = { audit, spacing, details }; + const managed = new WeakMap(); + let current = null; + let stepping = false; + + window.__story = { + install() { + const style = document.createElement("style"); + style.id = "story-style"; + style.textContent = css; + document.head.appendChild(style); + document.querySelectorAll(".swap > .to").forEach(letters); + document.querySelectorAll("[data-out]").forEach((el) => { + const b = box(el); + if (getComputedStyle(el).display === "inline") el.style.display = "inline-block"; + el.style.overflow = "hidden"; + el.style.maxHeight = Math.ceil(b.height) + "px"; + el.style.maxWidth = Math.ceil(b.width) + "px"; + }); + document.querySelectorAll("body *").forEach((el) => { + const b = box(el); + const wave = Math.round(Math.min(420, Math.max(0, b.top) / innerHeight * 320 + Math.max(0, b.left) / innerWidth * 100)); + el.style.setProperty("--wave", wave + "ms"); + }); + document.querySelectorAll("*").forEach((el) => { + if (getComputedStyle(el).animationName !== "none") el.style.animationPlayState = "paused"; + }); + document.documentElement.offsetHeight; + document.documentElement.classList.add("story-live"); + const follow = () => { + if (current && !stepping) current(); + requestAnimationFrame(follow); + }; + requestAnimationFrame(follow); + }, + reset() { + const root = document.documentElement; + root.classList.remove("story-live", ...steps.map((_, index) => "s" + (index + 1))); + layer().className = ""; + layer().innerHTML = ""; + current = null; + root.offsetHeight; + root.classList.add("story-live"); + }, + tick(ms) { + stepping = true; + for (const animation of [...document.getAnimations(), ...window.parent.document.getAnimations()]) { + if (!managed.has(animation)) { + if (animation.playState !== "running") continue; + managed.set(animation, ms); + animation.pause(); + } + const time = ms - managed.get(animation); + const end = animation.effect ? animation.effect.getComputedTiming().endTime : Infinity; + if (Number.isFinite(end) && time >= end) animation.finish(); + else animation.currentTime = time; + } + if (current) current(); + }, + step(k) { + document.documentElement.classList.add("s" + k); + }, + show(kind) { + const node = layer(); + node.className = ""; + node.innerHTML = ""; + const update = (kinds[kind] || (() => {}))(); + current = typeof update === "function" ? update : null; + if (current) current(); + node.offsetHeight; + node.classList.add("s-on"); + }, + settle() { + layer().classList.add("s-on", "s-fixed"); + }, + clear() { + layer().classList.remove("s-on"); + }, + }; +})(); +`; + +const types = { ".html": "text/html", ".css": "text/css", ".js": "text/javascript", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".avif": "image/avif", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".woff": "font/woff", ".mp4": "video/mp4" }; +const server = createServer((request, response) => { + const path = decodeURIComponent(new URL(request.url, "http://local").pathname); + if (path === "/__stage.html") { + response.writeHead(200, { "content-type": "text/html" }); + return response.end(stage); + } + const target = normalize(join(root, path.replace(/^\/__story\//, "/"))); + if (!target.startsWith(root) || !existsSync(target)) { + response.writeHead(404); + return response.end(); + } + response.writeHead(200, { "content-type": types[extname(target).toLowerCase()] ?? "application/octet-stream" }); + response.end(readFileSync(target)); +}); +await new Promise((done) => server.listen(0, "127.0.0.1", done)); +const base = `http://127.0.0.1:${server.address().port}`; + +const FPS = 60; +const LENGTH = 10; +const frames = join(root, ".story-frames"); +if (!play) { + rmSync(frames, { recursive: true, force: true }); + mkdirSync(frames, { recursive: true }); +} + +const chrome = [process.env.CHROME_PATH, "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/usr/bin/google-chrome", "/usr/bin/chromium"].find((path) => path && existsSync(path)); +const { chromium } = await browserKit(); +const browser = await chromium.launch({ ...(chrome ? { executablePath: chrome } : {}), headless: !play }); +const sleep = (ms) => new Promise((done) => setTimeout(done, Math.max(0, ms))); +let total = 0; +try { + const page = await browser.newPage(play ? { viewport: { width: Math.round(FRAME.width * VIEW), height: Math.round(FRAME.height * VIEW) } } : { viewport: FRAME, deviceScaleFactor: 2 }); + await page.goto(`${base}/__stage.html`, { waitUntil: "networkidle" }); + const frame = page.frames().find((item) => item !== page.mainFrame()); + await frame.waitForLoadState("networkidle"); + await frame.evaluate(async () => { + const families = new Set(); + for (const sheet of document.styleSheets) { + let rules = []; + try { + rules = [...sheet.cssRules]; + } catch {} + const walk = (list) => list.forEach((rule) => { + if (rule.cssRules) walk([...rule.cssRules]); + const value = rule.style && rule.style.fontFamily; + if (value) value.split(",").forEach((name) => families.add(name.trim().replace(/^["']|["']$/g, ""))); + }); + walk(rules); + } + await Promise.all([...families].flatMap((family) => [400, 500, 600, 700, 800].map((weight) => document.fonts.load(`${weight} 16px "${family}"`).catch(() => null)))); + await document.fonts.ready; + }); + await frame.addStyleTag({ content: `html { padding: ${padTop}px ${padRight}px 0 ${padLeft}px !important; }` }); + await frame.addScriptTag({ content: runtime }); + await frame.evaluate(() => window.__story.install()); + await page.waitForTimeout(300); + + const slop = await frame.evaluate(() => document.querySelectorAll("[data-slop]").length); + const call = (name, value) => () => frame.evaluate(([name, value]) => window.__story[name](value), [name, value]); + const plan = []; + const at = (time, run) => plan.push({ time, run }); + let cursor = 0.5; + if (slop) { + at(cursor, call("show", "audit")); + at(cursor + 0.9, call("clear")); + cursor += 1.1; + } + const blocks = steps.map((name) => (kindOf(name) ? 1.75 : 1.25)); + const fit = Math.min(1, (LENGTH - cursor - 1.6) / blocks.reduce((sum, block) => sum + block, 0)); + steps.forEach((name, index) => { + const kind = kindOf(name); + if (kind) { + at(cursor, call("show", kind)); + at(cursor + 0.35 * fit, call("step", index + 1)); + at(cursor + 1.35 * fit, call("settle")); + at(cursor + 1.7 * fit, call("clear")); + } else { + at(cursor, call("step", index + 1)); + } + cursor += blocks[index] * fit; + }); + total = cursor + 1.6; + plan.sort((a, b) => a.time - b.time); + + if (play) { + console.log("playing in the browser window, close it to stop"); + while (browser.isConnected()) { + await frame.evaluate(() => window.__story.reset()); + await sleep(400); + const begin = Date.now(); + for (const { time, run } of plan) { + await sleep(begin + time * 1000 - Date.now()); + await run(); + } + await sleep(begin + total * 1000 - Date.now()); + } + } else { + let next = 0; + for (let index = 0; index <= Math.round(total * FPS); index++) { + const now = index / FPS; + while (next < plan.length && plan[next].time <= now + 1e-6) await plan[next++].run(); + await frame.evaluate((ms) => window.__story.tick(ms), now * 1000); + await page.screenshot({ path: join(frames, `f-${String(index).padStart(5, "0")}.jpg`), type: "jpeg", quality: 92 }); + } + } +} catch (error) { + if (!play) throw error; +} finally { + await browser.close().catch(() => {}); + server.close(); +} +if (play) process.exit(0); + +execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-framerate", String(FPS), "-i", join(frames, "f-%05d.jpg"), "-vf", `scale=${FRAME.width * 2}:${FRAME.height * 2}:flags=lanczos`, "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "slow", "-movflags", "+faststart", resolve(out)]); +rmSync(frames, { recursive: true, force: true }); +console.log(`saved ${resolve(out)}, ${steps.length} steps, ${total.toFixed(1)}s`);