{"id":87242,"plugin_id":"plugin_asdk_app_6ad8bade38cc8191b8bbd302b4fd08bd","kind":"skill","collection_source":"plugin_package","comparison_source":null,"observed_at":"2026-11-09T12:08:00.674Z","digest":"d540536effbf9d40746ba30962ef9d229fea7821fdf72083895d2a7d4c4450ed","against":null,"payload":{"description":"Recreate an HTML/CSS banner (absolute-positioned elements over a fixed-size canvas — the shape of HTML5 ad banners, email banners, or design-tool HTML exports) as a native, editable Abyssale design, including basic CSS/GSAP animation mapped to Abyssale's tween system. Use when the user gives HTML/CSS for a banner and wants it rebuilt in Abyssale via import_design_from_json, not just referenced. Does not handle canvas-rendered banners; animation support is best-effort, not a full reproduction of every CSS/GSAP effect.","included_files":[],"name":"html-to-abyssale","skill_md_contents":"---\nname: html-to-abyssale\ndescription: Recreate an HTML/CSS banner (absolute-positioned elements over a fixed-size canvas — the shape of HTML5 ad banners, email banners, or design-tool HTML exports) as a native, editable Abyssale design, including basic CSS/GSAP animation mapped to Abyssale's tween system. Use when the user gives HTML/CSS for a banner and wants it rebuilt in Abyssale via import_design_from_json, not just referenced. Does not handle canvas-rendered banners; animation support is best-effort, not a full reproduction of every CSS/GSAP effect.\n---\n\n# HTML → Abyssale conversion\n\nGoal: read an HTML/CSS banner's structure and rebuild it as native Abyssale layers\n(text/button/shape/image), one element per visible node — not flattened into a\nscreenshot. Animation is carried over on a best-effort basis: reach for the closest\nnative Abyssale tween, don't try to reproduce every CSS/GSAP effect exactly.\n\n## Scope\n\n**Handles**: absolute-positioned banners over a fixed-size canvas — the structure\nHTML5 ad banners, email banners, and design-tool exports actually use (a container\nwith a fixed `width`/`height`, children `position: absolute` with `left`/`top`).\n\n**Does not handle: canvas-rendered banners.** Some real HTML5 ad banners render\neverything by drawing onto one or more `<canvas>` elements from JavaScript, with no\nDOM structure to inspect — no `left`/`top`, no text nodes, just imperative draw calls\ninside `.js` files. Detect this by checking whether the visible content lives inside\n`<canvas>` tags with empty bodies (as opposed to `<img>`/`<div>` children). If so, say\nso and stop — don't attempt a conversion, and don't fall back to a screenshot import\nwithout the user explicitly asking for that specific (much lower-fidelity) outcome.\n\n**No single fixed canvas (flexbox-centered `body`, no explicit `width`/`height` on the\ncontainer)**: don't hand-estimate pixel positions from reading the CSS — guessed\ncoordinates have been wrong enough to cause visible overlaps. Measure real positions\ninstead (see \"Measuring real positions\" below).\n\n**A source with a `<video>` element** maps to a native `video` layer — just a plain\nhosted `src` (its only required property; `muted` and `opacity` are the only other\nones — map a source `muted` attribute to `muted: true`), positioned/sized like any\nother layer, confirmed via a real conversion (a hero `<video autoplay muted loop>` →\none `video` layer with `properties.default.src` set to the same hosted `.mp4` URL).\nLike any layer, it spans the whole timeline unless you give it an `animation` window\n(see the animation rules below). Get the exact shape fresh with\n`get_design_import_example(type: \"animated-video\")` rather than reconstructing it.\n\n**One HTML source becoming a multi-format Abyssale design** — several formats\nsharing one layer set — is confirmed working: build `formats` as a list of `{name,\nwidth, height, background_color}` entries, and key **every** layer's `layout` **and**\n`properties` by format name (a `default` key can hold the values shared by all\nformats; a flat `properties` is rejected once there are two formats). If\nthe format's box materially changes proportions, redesign that layer's numbers for the\nnew box rather than naively scaling — a 320×480 hero video area doesn't have to stay\nthe same height in a 300×600 half-page, and the official `static-multi-format`/\n`animated-video` examples both hand-tune per-format numbers rather than scale. See\n\"Multi-format and `slide` tweens don't mix safely\" below before animating layers whose\n`layout` differs by format.\n\n## Steps\n\n0. **Before writing any payload by hand, fetch a fresh official example** —\n   `get_design_import_example(type: \"animated\")` (or `\"static\"`, `\"animated-video\"`,\n   `\"static-multi-format\"` as fits) — rather than reconstructing the shape from memory\n   or an earlier conversation summary — common mistakes otherwise: `formats` built as\n   an object instead of a list, a top-level `animations` map instead of a per-layer\n   `animation` block, tween keyframes missing their `data.time`. The example is the\n   source of truth; this skill's notes are a supplement to it, not a replacement. Every example contains `target.project_uuid:\n   \"YOUR_PROJECT_UUID\"` — replace it with a real project: call `list_projects` and ask\n   the user which one, or `create_project` for a new one.\n\n1. **Find the canvas.** Locate the outer container with an explicit fixed\n   `width`/`height` (in px) and its `background-color` — this becomes one entry in\n   the Abyssale payload's `formats` **list** (`\"formats\": [{\"name\": ..., \"width\": ...,\n   \"height\": ..., \"background_color\": ...}]`) — **a list, not an object keyed by\n   format name**: sending `{\"formats\": {\"custom-300x250\": {...}}}` fails validation\n   with `formats: Not a valid list`, a mistake easy to make since `layout` and\n   `properties` on each *layer* genuinely are keyed-by-format-name objects — `formats`\n   at the payload root is the one exception, always an array.\n\n2. **Map each visible child to a layer**, using its own `left`/`top`/`width`/`height`\n   directly as `layout.<format>.{x,y,width,height}` — no coordinate accumulation\n   needed, since CSS absolute positioning within the fixed canvas already gives final\n   coordinates. Skip purely structural wrappers with no visible fill/text/image.\n\n   | CSS element | Abyssale layer |\n   |---|---|\n   | A `<div>`/text node with just text content | `text` — `payload`, `color`, `font`, `font_size` (required, 2–1000 — a starting size that auto-shrinks to fit the box), `font_weight`, `text_align`, `vertical_align`. A source `<br>` or hard line break becomes a literal `\\n` inside `payload` (e.g. `PUSH<br>HARDER` → `\"payload\": \"PUSH\\nHARDER\"`) — don't try to split it into two separate text layers. |\n   | A `<div>` styled as a button/pill (background + radius + text, e.g. a CTA) | `button` — use this native type directly, don't decompose into shape+text. Same text properties, plus `background_color`, `radius`, `padding` (a number or `{vertical, horizontal}` — always set it, see the note right after this table) and `alignment` (where the button sits inside its layer box, default `middle center`). **No `vertical_align`** — including it fails validation with `unknown_field`. |\n   | A `<div>` styled as a filled/bordered box with no text | `shape` — `background_color`, `radius`, optional nested `stroke: {color, width}` |\n   | An `<img>`, or a CSS `background-image` on a container | `image` — `src` as a public HTTPS URL (preferred); otherwise a `data:image/...;base64,` URI of at most 100 KB (tell the user if you recompressed it); a bare local filename only if you can run shell commands — see `abyssale-import` for that upload flow |\n   | An inline `<svg>` | No native SVG/vector layer type exists — rasterize it (e.g. with `cairosvg`) and import as one `image` layer. Don't try to decompose an SVG's sub-elements into separate Abyssale layers unless there's a real reason to animate them independently — flatten it as one piece by default |\n   | Text baked into a full-canvas transparent PNG rather than a live DOM text node (seen on real production banners using custom stroked/bevelled typography no web font could reproduce) | `image`, not `text` — there's no live font/color/content to extract |\n   | A `<video>` element | `video` — `properties.default.src` set to a hosted `.mp4` URL, `layout` as usual. Confirmed working; see the Scope section above. |\n\n**Text auto-wraps by declared `width` — a manual `\\n` is only needed for a source\n`<br>`.** Confirmed on a headline with no line break in its source markup at all: given\na `width` narrower than the text's natural single-line size, Abyssale wrapped it to two\nlines on its own, and did so identically across two formats whose `layout` declared\ndifferent widths for the same layer — no `\\n` needed, and none should be added, when\nthe source doesn't have an explicit break.\n\n**A `button`'s visible box is sized from its label plus `padding`, and placed inside\nthe layer box by `alignment`.** Always set an explicit `padding` derived from the\nsource CSS (its own `padding`, or an estimate from the box size and font size if the\nsource used flex-centering). With the default `alignment` (`middle center`) the button\nis centered in its layer box — use e.g. `\"middle left\"` to match a CSS box anchored on\nits left edge. The layer box must be large enough for the label: if the text can't fit,\nthe render fails — give the box enough width, or set `min_font_size` if the label may\nshrink. Check buttons near a canvas edge or another element on the first render.\n\n3. **Colors**: CSS hex maps directly (`#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`).\n   Convert `rgb()`/`rgba()`/`hsl()` to hex (`#RRGGBBAA` with alpha) — those functions\n   are rejected. Linear and radial **gradients are native** as strings —\n   `linear-gradient(<x>% <y>% <x1>% <y1>%,<off>% <color> <alpha>,…)` /\n   `radial-gradient(<cx>% <cy>% <r>%,<off>% <color> <alpha>,…)`, 2–8 stops — on a\n   text `color`, a shape/button `background_color` and the format background. Check the\n   exact accepted syntax in the validation error if one is rejected.\n\n4. **Fonts**: Abyssale needs a font **uuid**, not a family name — call `list_fonts`\n   filtered by `name`. If both a Google Fonts result (`type: \"google\"`) and a custom\n   upload of the same name exist, prefer the Google Fonts one unless the brand requires\n   its own file — then check the first render for spacing. A weight the font doesn't\n   have is replaced by the nearest one, reported as a `font_weight_adjusted` warning on\n   the real import (`validate_only` doesn't resolve fonts).\n\n5. **Validate, then import for real**: `import_design_from_json(validate_only: true)`\n   first, fix schema errors, then `validate_only: false`. An error returned by the call\n   itself (no `import_id`, e.g. `unknown_font`, `unreachable_src`) means nothing was\n   created: fix the payload and submit again. A clean `validate_only`\n   creates nothing and doesn't check fonts, image URLs or the project — only the real\n   import's `warnings` array surfaces a font-weight substitution or a text that didn't\n   fit (`text_fit_failed`; pass `strict: true` to make that a failure instead). The real\n   import waits up to ~60 s and usually returns the final status; if it comes back with\n   `is_finalized: false`, check again with `check_design_import_status` (statuses:\n   `WAITING_FOR_VALIDATION`, `QUEUED`, `PROCESSING`, `DONE`, `FAILED`). On `DONE` the\n   design id is `result.uuid`; on `FAILED` read `error`. Read every warning, then\n   render the design in step 6.\n\n6. **Visually QA** (each render spends Abyssale credits) — `generate_static_banner`\n   for a static design; for `animated` designs it rejects outright\n   (`template_not_static`), use `generate_banners_async` with `image_file_type: \"mp4\"`\n   instead (it waits up to ~60 s and usually returns the file URL directly). If you can\n   run shell commands, download it and pull a few frames at different timestamps\n   (`ffmpeg -ss <t> -i render.mp4 -frames:v 1 out.jpg`) to confirm animation actually\n   progresses — don't trust a clean `validate_only` alone. Without a shell or ffmpeg,\n   give the user the mp4 URL and say plainly that frame-by-frame QA wasn't done.\n   Pull a frame near the **end** of the timeline specifically, not just mid-animation\n   ones: a layer whose `start_at_s`/`end_at_s` window was mistakenly scoped to its own\n   tween (see below) renders fine early on and only reveals the bug by vanishing late,\n   which a check at t=0 and t=mid alone won't catch.\n   A plain solid-color shape's rotation can't be visually confirmed this way (no\n   asymmetry to judge by) — use a shape with a visible feature, or note plainly that\n   rotation wasn't verified.\n\n## Animation — best effort, not a full reproduction\n\nIf the source animates — CSS `@keyframes`/`transition`, or a GSAP timeline — set the\ndesign's `type` to `\"animated\"` and add an animation block at the payload **root**\n(`\"animation\": {\"duration\": <seconds>, \"screenshot_at_s\": <seconds>}` — `duration` is\nrequired, 1–60 s; `screenshot_at_s` is optional and defaults to the duration) plus one\n**per animated layer** (`\"animation\": {\"start_at_s\", \"end_at_s\", \"tweens\": [...]}`).\nOmitting the root one fails with `missing_required`. A layer with no `animation`\nblock simply spans the whole timeline. Animated formats must fit within 1920×1200,\nand animated designs require a plan that includes them (`feature_not_in_plan`\notherwise).\n\n**A layer's `start_at_s`/`end_at_s` is its presence window on the timeline, not just\nits tween's active span — get this wrong and the element vanishes right when its\nfade/slide-in finishes, instead of staying visible.** Confirmed by rendering a design\nwhere every layer's `start_at_s`/`end_at_s` was set to match its own fade-in's timing\n(e.g. `0.1`–`0.7` for a layer whose tween ran 0.1→0.7): the layer was only ever\nrendered *inside* that window — it wasn't there before `start_at_s`, and it\ndisappeared again the instant `end_at_s` passed, discarding the fade-in's whole point\n(that the element should then stay on screen). Pulling frames from the rendered mp4 at\nincreasing timestamps showed elements popping out one by one as their individual\nwindows closed, until only the last-arriving layer remained. **Leave the window out\n(it defaults to `start_at_s: 0` / `end_at_s: <root duration>`), or set exactly those\nvalues, on every layer that should stay visible** (matching the\nofficial multi-format example, where every layer spans the full timeline even though\nits tween only moves during a sub-range of it) — the tween's own keyframe `data.time`\nvalues are what stagger the actual movement inside that constant presence window; the\nengine holds the first keyframe's value before the tween starts and the last\nkeyframe's value after it ends.\n\n**Tween vocabulary** (confirmed against the official reference,\ndevelopers.abyssale.com/rest-api/designs/import/animated):\n\n| Tween type | Keyframe `attr` | Range |\n|---|---|---|\n| `slide` | `left`, `top` (px, both required) | signed px |\n| `scale` | `scale` (the tween itself can also take `scale_origin_x` — `left`/`center`/`right` — and `scale_origin_y` — `top`/`middle`/`bottom` — always as a pair; they're tween fields, not keyframe attrs) | **0–100, hard-capped — 100 is the ceiling, not just \"as designed.\"** A keyframe above 100 fails validation with `out_of_range` (confirmed: `115` rejected outright). A CSS pulse that overshoots past 100% (e.g. `scale(1)` → `scale(1.15)`) has no direct equivalent — remap it as a grow-in *up to* 100 instead (e.g. `88` → `100`), never as `100` → `>100`. |\n| `rotate` | `angle` | unbounded signed degrees, not clamped to ±360 |\n| `fade` | `opacity` | 0.0–1.0 (a fraction — unlike `scale`'s 0–100, and unlike a layer's own `properties.opacity`, which is 0–100) |\n| `audioFade` | `volumeEffect` | 0.0–1.0 |\n| `textEffect` | `typewriting` (0 or 100 only) + `textEffectType` (`classic`, `softFade`, `focusIn`, `riseUp`, `riseDown`) | — |\n\nEach layer can have **at most one tween of each type** (two `fade` tweens on one layer\nfail with `invalid_payload`) and each tween needs at least 2 keyframes — chain several\nkeyframes inside one tween instead. Only a numeric keyframe `data.time` is enforced;\n`data.type` (`start`/`middle`/`end`) and `ease` are the normal shape but optional.\n\nGSAP ease strings carry over directly into `ease`, **including parametrized ones**\n(`\"back.out(1.7)\"`, `\"elastic.out(1, 0.5)\"` both validate and render correctly).\n\n**Multi-format and `slide` tweens don't mix safely.** A layer's `tweens` are declared\n*once*, not per format, but a `slide` tween's `attr.left`/`attr.top` are absolute\ncanvas pixels — and if that layer's `layout` differs by format (the normal case for a\nmulti-format design), those absolute values can only be correct for one format's box.\nThe import docs now confirm it: the single `animation` block is applied to every\nformat as is, so `slide` offsets (and a `scale` tween's origin) are the same pixels in\nevery format — they are not reinterpreted per format. So on a multi-format animated\ndesign, restrict `slide` to a layer whose `layout` box is identical across every\nformat, and use rotate/scale/fade for anything whose position varies by format (none\nof those three reference a position). Per-format slide distances can only be adjusted\nin the Abyssale editor after the import.\n\n**`.from()` is not \"`.to()` reversed\" — check what it actually animates FROM and TO.**\nGSAP's `.from(el, {opacity: 1, ...})` animates from the given values TO the element's\nown resting CSS state. If that resting state is `opacity: 0` (e.g. a `.hidden` class),\nthe call is a flash-then-fade-**out**, not a fade-in — the opposite of what those same\nvalues mean on `.to()`. Don't \"normalize\" a `.from()` call to match its `.to()`\nsiblings without checking the element's CSS resting state first — assuming it must be\nan authoring mistake produced a real, confusing bug (an element stayed at opacity 1\nfor the rest of the timeline instead of correctly disappearing, ghosting behind every\nlater scene).\n\n**A \"hold\"** (appears, stays, then disappears) is a validated pattern: pack 4\nkeyframes into one `fade` tween — `[opacity:0 @ start]`, `[opacity:1 @ fade-in end]`,\n`[opacity:1 @ hold end — same value, later time]`, `[opacity:0 @ fade-out end]`. If\nseveral layers share one box and take turns being visible (a sequential multi-scene\nbanner), give each its own hold-pattern tween with non-overlapping visible windows —\ncompute absolute times from the GSAP timeline by hand (`\"+=N\"` adds `N`s after the\ncurrent end of the timeline; `\"<\"` starts alongside the immediately-preceding tween's\nstart). If two scenes end up visible at once, the likely cause is a layer that never\nactually reaches opacity 0 — re-check its keyframes (and its `.from()`/`.to()` intent).\n\n### Effects with no native equivalent — approximate simply, don't over-engineer\n\nNone of these have a native Abyssale tween. Use the closest single native\napproximation and move on — don't build an elaborate multi-layer or multi-keyframe\nreproduction for the sake of fidelity:\n\n- **A curved/bezier motion path** → a plain `slide` from the path's start point to its\n  end point (skip the intermediate waypoints unless there's a strong reason to keep\n  them — a straight line is already a reasonable best-effort stand-in).\n- **`repeat`/`yoyo` (looping)** → play it once. Only chain a couple of extra keyframe\n  pairs manually (e.g. `angle` 0→360→720) if a short, obviously-bounded repeat is\n  clearly the point of the effect — never attempt to fake a long or indefinite loop.\n- **Linear/radial gradients** → use the native gradient string (see step 3) on the\n  shape/button background or text color — no rasterizing needed.\n- **`filter: blur()`, `backdrop-filter`, glassmorphism, gradients beyond what the\n  native syntax supports** → don't approximate with a flat-color Abyssale shape, it\n  reads as visibly wrong rather than simplified. If the effect is purely decorative,\n  it's fine to drop it. If it matters to the design and you can run shell commands,\n  rasterize just that portion with a headless browser\n  (`google-chrome --headless=new --screenshot=out.png \"file://<path>\"`, with the\n  layers you're keeping native hidden via injected `visibility: hidden` CSS first)\n  and import the result as one `image` layer (hosted URL, or a data URI of at most\n  100 KB). Without a shell, drop it and tell the user.\n- **Per-character text stagger** → animate the whole text block as one unit; the\n  stagger itself is lost, not approximated.\n- **3D transforms (`rotateX`/`rotateY`)** → drop the 3D aspect, keep any accompanying\n  2D motion (fade/slide) on the same element.\n- **Multiple independently-animated pieces inside one SVG** → flatten the whole SVG\n  to one rasterized `image` layer and apply a single simple tween to it (fade/scale\n  in, say) rather than rasterizing each sub-element separately to preserve individual\n  motion — that level of decomposition is not worth it for a best-effort pass.\n\n## Measuring real positions (non-fixed-canvas layouts)\n\nThis needs shell access and headless Chrome. Without them, ask the user for a\nfixed-size version of the banner (explicit container `width`/`height` and absolute\nchildren), or estimate positions and tell the user plainly that they're estimates to\ncheck in the Abyssale editor.\n\n1. Inject a script before `</body>` that, after a `setTimeout` matching the\n   animation's duration, calls `getBoundingClientRect()` on the relevant elements and\n   writes the result as JSON into a `<pre id=\"__measurements\">` appended to `body`.\n2. `google-chrome --headless=new --disable-gpu --no-sandbox --virtual-time-budget=<ms>\n   --window-size=<w>,<h> --dump-dom \"file://<path>\"` — the time budget fast-forwards\n   past the injected `setTimeout`.\n3. Grep the measurements out of the dumped HTML and use those as `layout` coordinates.\n\nThe `--window-size` you choose is the layout's real basis (it decides whether a CSS\n`max-width` kicks in) — pick one deliberately.\n\n**`--screenshot` can silently truncate the paint well short of the requested\n`--window-size`, independent of your page's content.** Confirmed on this environment\nwith a single flat, fully-static `<div>` sized exactly to `--window-size`: the bottom\nportion of the image came back plain white instead of the div's background color, even\nthough an injected script confirmed the div's `getBoundingClientRect()` height matched\nthe window exactly. Requesting a **taller** window than the actual content needs (e.g.\n`--window-size=<w>,<h+200>`) and cropping the result down to `<w>x<h>` afterward made\nthe same content paint correctly end to end — so when a `--screenshot` capture looks\nsuspiciously cut off, don't assume it's a real CSS/layout bug in the page: verify with\na computed-style probe (inject a script that reports `getBoundingClientRect()`/\n`getComputedStyle()` into a `<pre>`, then `--dump-dom` — see steps 1–2 above) before\n\"fixing\" a layout that may already be correct.\n\n## Notes\n\n- CSS `line-height` in `em`/unitless form converts to Abyssale's percentage form\n  (`1.4` → `140`).\n- An unknown property name on a layer is a hard validation error (`unknown_field`) —\n  don't assume every part of the API silently ignores unrecognized keys.\n- Images: a hosted HTTPS URL is always the first choice. Inline base64 is only for\n  small assets (at most 100 KB) and only when a tool produced it — never type base64\n  yourself, a long string is easy to corrupt across tool calls. A bare local filename\n  plus the upload flow (see `abyssale-import`) works only when you can run shell\n  commands; never ask the user to run the upload commands.\n"},"changes":[],"summary":"First saved snapshot. No earlier version is available for comparison.","summary_kind":"deterministic","summary_metadata":{}}