From 6086963b286afa9368a841ed0acb1770325d327c Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 2 Aug 2026 11:57:01 +0200 Subject: [PATCH] docs(skills): stop forbidding the target register and correct the card rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The authoring rules are the de-facto schema an LLM generates from, and two of them worked against the project. typography-readability required a line-height between 1.4 and 1.6 and declared that below 1.2 lines collide and text becomes unreadable. Sub-1.0 leading is the single most identifiable trait of the register this project is building toward, and both shipped templates violated the rule — dark-premium at 1.04 throughout, 1600-style between 0.80 and 1.02. It also contradicted the brutalist rule with no precedence between them. There is now a statement register documented with measured scale and tracking, and an explicit precedence. Measurement also showed the two templates use opposite tracking signs — 1600-style positive, dark-premium negative — so both are described rather than one being treated as the error. The card rules were not stale but reversed. stat-cards advised using stat over counter for centring; measurement shows counter centres exactly on the content box while stat has no intrinsic, overflows its own box, and renders nothing at all when three sit in a flex row. counter-standalone's claimed missing baseline correction is not reproducible; the real defect is horizontal and validator-silent. grid-card-height's BAD case does not reproduce on this build. card-flex-layout never mentioned that 23 component types are invisible without an explicit height — its own good example works only because its icon carries one. The component count in SKILL.md said 51 against 57 in the enum, and qr_code was missing from the catalogue entirely. CLAUDE.md already carried the correct count; that part of the brief was stale. All 23 fenced JSON blocks in the touched files were extracted and validated, including two pre-existing broken ones that the sweep caught. Closes #130 --- .claude/skills/rustmotion/SKILL.md | 43 +++++++++++++---- .../rustmotion/rules/1600-brutalist-style.md | 4 +- .../rustmotion/rules/card-flex-layout.md | 12 ++++- .../rustmotion/rules/counter-standalone.md | 32 ++++++++++--- .../rustmotion/rules/grid-card-height.md | 45 +++++++---------- .../rules/responsive-device-sizing.md | 14 +++--- .claude/skills/rustmotion/rules/stat-cards.md | 40 +++++++++++----- .../rules/typography-readability.md | 48 +++++++++++++++++-- 8 files changed, 169 insertions(+), 69 deletions(-) diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index fbcaa49..d38149c 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -173,7 +173,7 @@ The user validates or adjusts the plan before proceeding. 1. All font sizes meet the floor for the target device (see [rules/typography-readability.md](rules/typography-readability.md)) 2. `start_at + delay + duration ≤ scene_duration` for every animation (see [rules/animation-completion-budget.md](rules/animation-completion-budget.md)) 3. Text color contrasts correctly with the scene/card background (dark bg → white text, light bg → dark text) -4. No `counter` component inside a card (see [rules/counter-standalone.md](rules/counter-standalone.md)) +4. If a `counter` is inside a card (this is fine — it centers correctly), make sure the card/parent is at least as wide as the counter's worst-case digit width, since the counter box never shrinks to fit (see [rules/counter-standalone.md](rules/counter-standalone.md)) 5. Scene duration ≥ reading time of all text (`word_count ÷ 2.5`) (see [rules/scene-pacing.md](rules/scene-pacing.md)) 6. If dynamism level ≥ 2: at least one non-text element per scene has a continuous effect (`float_3d`/`wiggle`/`orbit` with `loop: true`). Never apply continuous motion to primary text. See [rules/dynamic-depth.md](rules/dynamic-depth.md). @@ -197,7 +197,8 @@ For each scene in the validated plan: ### Design guidelines - **Scene duration:** Use `max(animation_budget + 0.5s_dwell, word_count ÷ 2.5)`. Never use "3-5s" as a flat default — text-heavy scenes need more. See [rules/scene-pacing.md](rules/scene-pacing.md) for the lookup table. -- **Typography floor:** Title ≥ 90px (mobile) / 45px (desktop). Body ≥ 48px (mobile) / 24px (desktop). `line-height: 1.4–1.6` on multi-line text. White text on dark bg; dark text on light bg. See [rules/typography-readability.md](rules/typography-readability.md). +- **Typography floor:** Title ≥ 90px (mobile) / 45px (desktop). Body ≥ 48px (mobile) / 24px (desktop). `line-height: 1.4–1.6` on multi-line **body copy**. White text on dark bg; dark text on light bg. See [rules/typography-readability.md](rules/typography-readability.md). +- **Statement register** (1600.agency/Machina, Aikido-style dark-premium): display type 200–400px+ on desktop against 30–56px labels, `line-height` 0.80–1.05 (tight, deliberate — not the body-copy floor above), `letter-spacing` **positive** for brutalist/uppercase or **negative** (e.g. `-2` uniform) for the dark-premium register. See [rules/typography-readability.md](rules/typography-readability.md#statement-register-hierarchy-scale-tracking) and [rules/1600-brutalist-style.md](rules/1600-brutalist-style.md). - **Animation budget:** `start_at + delay + duration ≤ scene_duration` for every animated component. See [rules/animation-completion-budget.md](rules/animation-completion-budget.md). - **Color palette:** Pick one of the 4 pre-built palettes (Dark Tech / Corporate / Playful / Minimal) in Phase 2 and never deviate. See [rules/color-palettes.md](rules/color-palettes.md). - **Animation patterns:** Stagger entrances within a scene (0.1-0.3s delays). Use fade/slide transitions between scenes. @@ -218,7 +219,7 @@ Read individual rule files for detailed explanations, GOOD/BAD examples, and con - [rules/validate-json.md](rules/validate-json.md) - Always validate generated JSON with `rustmotion validate` before presenting - [rules/geometry-safety.md](rules/geometry-safety.md) - Keep all content inside the viewport: `white-space`, `auto_scroll`, `overflow` semantics + violation kinds - [rules/even-dimensions.md](rules/even-dimensions.md) - Use even width/height for H.264 encoding -- [rules/counter-standalone.md](rules/counter-standalone.md) - Counter must be standalone (no baseline correction inside cards) +- [rules/counter-standalone.md](rules/counter-standalone.md) - Counter centers correctly in a card; size the parent for its worst-case digit width or it overflows silently - [rules/vertical-align.md](rules/vertical-align.md) - Shape text vertical_align: use "top"/"middle"/"bottom" (NOT "center") - [rules/stagger-animations.md](rules/stagger-animations.md) - Stagger animations with increasing style.animation.delay - [rules/layer-order.md](rules/layer-order.md) - Layer order matters: first in array = behind, last = front @@ -737,7 +738,9 @@ Default duration: `0.5` seconds. --- -### Component Types (51 total) +### Component Types + +The engine has **57** component types total (`Component` enum, `crates/rustmotion-components/src/lib.rs:194-254`). The catalog below has a dedicated write-up with a JSON example for most of them; the rest are containers (`card`/`flex`, `div`, `grid`, `positioned`) covered in the "Mental Model: Think HTML/CSS" section above, plus `waveform`/`audio_spectrum` covered in [rules/audio-reactive.md](rules/audio-reactive.md). All components are discriminated by `"type"`. Rendered in array order (first = bottom). See Rule 7. @@ -1082,7 +1085,7 @@ Style: `font-size` (48.0), `font-family`, `color` (#FFFFFF), `background` ### 9. `counter` -Animated number counter. See Rule 4: must be standalone. +Animated number counter. Works fine inside a card (centers correctly) — see checklist item 4 / [rules/counter-standalone.md](rules/counter-standalone.md) for sizing the parent to its worst-case digit width. ```json { @@ -1424,7 +1427,7 @@ Data visualization with animation. Supports 12 chart types. **Root fields:** `chart_type` (required), `data` (`[{ "value", "label"?, "color"? }]`), `animated` (default true), `animation_duration` (default 1.5s), `colors` (custom palette) -Style: `width`, `height` (default 300×200) +Style: `width`, `height` — **required**. `chart` has no intrinsic sizing (no fallback in the engine — verified against `box_builder.rs`'s `component_intrinsic`/`apply_intrinsic_overrides`); omit them in a flex/grid container and the chart lays out at 0×0 and renders nothing. **Axes & grid (bar, line, area, stacked_bar, scatter, waterfall):** `show_grid`, `show_x_labels`, `show_y_labels`, `grid_color` (#FFFFFF15), `label_color` (#888888), `label_font_size` (12) @@ -1795,7 +1798,7 @@ Semi-circular arc gauge for KPIs and dashboards. **Root fields:** `value` (required), `min` (0), `max` (100), `label`, `fill_color` (#3B82F6), `track_color` (#333333), `track_width` (16), `start_angle` (135), `end_angle` (405), `show_value` (true), `animated` (true), `animation_duration` (1.5s) -Style: `width`, `height` (default 200×140) +Style: `width`, `height` — **required**, same as `chart`: `gauge` has no intrinsic sizing; without explicit dimensions it lays out at 0×0. ### 31. `sparkline` @@ -1815,7 +1818,7 @@ Mini inline chart without axes — ideal inside cards next to counters. **Root fields:** `data` (required — array of f64), `color` (#22C55E), `fill` (false — gradient fill under line), `fill_opacity` (0.2), `stroke_width` (2.0), `animated` (true), `animation_duration` (1.0s) -Style: `width`, `height` (default 120×40) +Style: `width`, `height` — **required**: `sparkline` has no intrinsic sizing (confirmed empirically — omitted, it renders zero pixels); always set explicit dimensions, e.g. `120×40`. ### 32. `stat` @@ -1835,7 +1838,7 @@ Composite KPI card: value + label + trend arrow + sparkline. **Root fields:** `value` (required — display string), `label`, `trend` (`{ "value": string, "direction": "up"/"down"/"neutral", "color"? }`), `sparkline_data` (array of f64), `sparkline_color`, `value_font_size` (48), `label_font_size` (14), `value_color` (#FFFFFF), `label_color` (#94A3B8) -Style: `width`, `height` (default 240×140) +Style: `width`, `height` — **required**: `stat` has no intrinsic sizing. Verified empirically — three `stat`s in a flex-row card with no explicit `width`/`height` render **zero pixels** (all three collapse to 0×0). Always set explicit dimensions, e.g. `280×180`. See [rules/stat-cards.md](rules/stat-cards.md). Trend uses `lucide:trending-up` / `lucide:trending-down` icons. Direction `"down"` with a positive connotation (e.g. churn decreasing) can use `"color": "#22C55E"` to override the default red. @@ -1910,7 +1913,7 @@ Continuous scrolling text — for tickers, breaking news, or decorative text ban **Root fields:** `content` (required), `speed` (100 — pixels/second), `direction` (`"left"` / `"right"`), `font_size` (24), `color` (#FFFFFF), `separator` (spacing between repeats, default 5 spaces) -Style: `width`, `height` (default 800 × font_size×2) +Style: `width`, `height` — **required**: `marquee` has no intrinsic sizing; always set explicit dimensions (it's exempt from viewport-overflow checks since its role is to bleed, but it still needs a box to scroll within). ### 37. `avatar_group` @@ -2235,6 +2238,26 @@ Style: `width`, `height` Points use real geographic coordinates (lat/lng). The world map is rendered as a dot grid using a 180×90 land bitmap. Points with `pulse: true` show expanding concentric rings. +### 52. `qr_code` + +Renders a scannable QR code from arbitrary content (URL, text, etc.). + +```json +{ + "type": "qr_code", + "content": "https://rustmotion.dev", + "size": 240, + "foreground_color": "#0F172A", + "background_color": "#FFFFFF" +} +``` + +**Root fields:** `content` (required), `size` (default 200 — used as both width and height unless overridden), `foreground_color` (#000000), `background_color` (#FFFFFF) + +Style: `width`, `height` — optional; falls back to `size` × `size` via `apply_intrinsic_overrides` if unset. + +The JSON `"type"` value is **`qr_code`** (snake_case of the `QrCode` enum variant), not `qrcode`. + --- ### Scene-Level Features diff --git a/.claude/skills/rustmotion/rules/1600-brutalist-style.md b/.claude/skills/rustmotion/rules/1600-brutalist-style.md index 0ca28de..7f7a4a7 100644 --- a/.claude/skills/rustmotion/rules/1600-brutalist-style.md +++ b/.claude/skills/rustmotion/rules/1600-brutalist-style.md @@ -8,7 +8,9 @@ Recette pour produire des vidéos dans l'esprit des studios de motion design typ ```json "fonts": [{ "family": "Anton", "source": "google", "weights": [400] }] ``` - puis `"font-family": "Anton"` sur chaque texte. `line-height` serré (0.92–1.02) pour empiler les lignes, `letter-spacing` 1–2 sur les gros titres, 4–8 sur les petits kickers. + puis `"font-family": "Anton"` sur chaque texte. `line-height` serré (0.92–1.02, jusqu'à 0.80 pour un mot/chiffre géant qui ne wrap jamais) pour empiler les lignes, `letter-spacing` **positif** 1–2 sur les gros titres, 4–8 sur les petits kickers. + + **Précédence explicite** : ce `line-height` serré prime sur la plage 1.4–1.6 de [typography-readability.md](typography-readability.md) — cette dernière régit le corps de texte / la prose multi-ligne, pas le display type de ce style. Les deux plages coexistent sans conflit dans un même scénario (un titre à 0.95 et une légende à 1.5 ne sont pas contradictoires, ce sont deux rôles différents). Voir la section "Statement register" de typography-readability.md pour le détail chiffré et l'autre registre (dark-premium, tracking **négatif**). - **Tailles (16:9, 1920×1080)** : hero 240–300px, titres 120–200px, chiffres 190px, kicker/label 32–56px. On cherche le texte qui remplit la largeur. - **Palette — blocs saturés alternés** : chaque scène est un aplat plein cadre qui bascule brutalement. Un seul aplat par scène, texte en noir `#0A0A0A` ou crème `#F5F0E8` selon le fond. - Jaune `#FFE500`, cobalt `#1A1AE5`, corail `#FF4A32`, vert acide `#00E676`, noir `#0A0A0A`. diff --git a/.claude/skills/rustmotion/rules/card-flex-layout.md b/.claude/skills/rustmotion/rules/card-flex-layout.md index a87f922..e22eb34 100644 --- a/.claude/skills/rustmotion/rules/card-flex-layout.md +++ b/.claude/skills/rustmotion/rules/card-flex-layout.md @@ -35,7 +35,17 @@ Key patterns: Children flow in the flexbox. Use `positioned` container for absolute positioning. -**Grid height warning:** Grid containers need an explicit `height` (not `"auto"`) to prevent rows from stretching to fill all available space. See [rules/grid-card-height.md](rules/grid-card-height.md). +**Grid sizing:** `height: "auto"` on a grid container sizes correctly to content — you don't need an explicit `height` just to avoid stretching. See [rules/grid-card-height.md](rules/grid-card-height.md). + +## 23 component types have no intrinsic size — they need explicit `width`/`height` + +Most components either measure their own content (`text`, `codeblock`, `counter`, `badge`, `table`, `terminal`, `caption`, `kbd`, `gradient_text`, `rich_text`) or get a computed fallback size from their own fields (`icon`-like shapes such as `avatar`, `divider`, `line`, `arrow`, `switch`, `slider`, `progress`, `list`, `timeline`, `notification`, `rating`, `qr_code`, `countdown`, `particle`, `cursor`, `connector`, `waveform`, `audio_spectrum`). The following **23 types have neither** (verified against `crates/rustmotion-components/src/box_builder.rs`'s `component_intrinsic` and `apply_intrinsic_overrides` — both are exhaustive `match`es and these fall through to their `_ => None` / `_ => {}` arms, and no component overrides `Painter::intrinsic_size` either): + +`shape`, `image`, `icon`, `svg`, `video`, `gif`, `callout`, `chart`, `comparison`, `dot_map`, `gauge`, `heatmap`, `lottie`, `marquee`, `mockup`, `pill_nav`, `skeleton`, `sparkline`, `stat`, `stepper`, `tag_cloud`, `tooltip`, `treemap` + +As a flex/grid child with no explicit `style.width`/`style.height`, any of these lays out at **0×0 and renders nothing** — not a smaller-than-expected box, no pixels at all. Confirmed by rendering: three `stat`s in a flex-row card with no explicit size produce a blank frame. `rustmotion validate` does not flag this (a 0×0 box doesn't overflow anything). + +Always give these components explicit `style.width`/`style.height` (or a fixed size via their own dedicated `size` field where one exists, e.g. `qr_code`'s `size`) wherever they're a flow child of a `card`/`flex`/`grid` — not just when the parent has `height: "auto"`. If a component silently doesn't render, check this list before assuming a schema-field-placement bug (see [rules/component-field-placement.md](rules/component-field-placement.md) for that other, more common cause of invisible components). **GOOD** (icon + text row): ```json diff --git a/.claude/skills/rustmotion/rules/counter-standalone.md b/.claude/skills/rustmotion/rules/counter-standalone.md index aef9f5f..77428b9 100644 --- a/.claude/skills/rustmotion/rules/counter-standalone.md +++ b/.claude/skills/rustmotion/rules/counter-standalone.md @@ -1,20 +1,27 @@ -# Rule: Counter Must Be Standalone +# Rule: Counter Centers Correctly — Size Its Parent for the Worst-Case Digit Width -`counter` renders correctly only as a standalone root-level scene child. Inside a `card`, there is no baseline correction — the counter renders from the top of the layout box and looks misaligned. +`counter` centers correctly whether it is a standalone scene child **or** nested inside a `card`. Verified by rendering both cases: with `text-align: center`, the counter's ink centre lands exactly on the content-box centre in both contexts, using the same paint-time centering math (`counter.rs`) regardless of parent — there is no separate "standalone" code path and no missing baseline correction inside cards. -`CounterIntrinsic` reserves space for the *largest absolute value* between `from` and `to` (worst-case digits). The layout never reflows mid-animation — so the parent must be wide enough to fit that worst-case width or the geometry validator fails with `viewport_overflow`. +## The real constraint: the box never shrinks to fit -**BAD:** +`CounterIntrinsic` reserves space for the natural (unwrapped) width of the *largest absolute value* between `from` and `to` (worst-case digits) — and, because the counter is atomic (`wrap: false`), that measurement **ignores** any known/definite width the parent offers; it always requests its full natural width. So if the parent (a `card`, or any fixed-width container) is narrower than that worst-case width, the counter's box is still sized to its content and **overflows the parent on both sides**. + +**This is validator-silent when the parent uses the default `overflow: visible` and the overflow stays inside the video viewport** — `rustmotion validate`'s geometry pass only flags content that leaves the *viewport*, not a parent container (see `CLAUDE.md`'s "Sécurité géométrique" section). Confirmed by rendering: a counter needing up to 7 digits inside a 300px-wide card visibly spills past both edges of the card, while `rustmotion validate` reports `Valid scenario` with zero geometry violations — only a schema-level warning ("display width changes from N to M chars — ensure the parent container is at least wide enough"). + +So: size the parent to the counter's worst-case digit width (or wider), and don't rely on `rustmotion validate` to catch it if you don't. + +**BAD** (card narrower than the counter's worst-case width — overflows silently, `to: 9_999_999` needs 7 digits' worth of width): ```json { "type": "card", + "style": { "width": 300, "height": "auto", "background": "#1E293B", "padding": 20 }, "children": [ - { "type": "counter", "from": 0, "to": 100 } + { "type": "counter", "from": 0, "to": 9999999, "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } } ] } ``` -**GOOD:** +**GOOD** (standalone — no parent width constraint): ```json { "type": "counter", @@ -26,3 +33,16 @@ "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center" } } ``` + +**GOOD** (inside a card — fine, as long as the card is wide enough for the worst-case digit width): +```json +{ + "type": "card", + "style": { "width": 700, "height": "auto", "background": "#1E293B", "padding": 20 }, + "children": [ + { "type": "counter", "from": 0, "to": 9999999, "style": { "font-size": 72, "color": "#FFFFFF", "font-weight": "bold", "text-align": "center", "width": "100%" } } + ] +} +``` + +`end_at` is a visibility toggle, not an animation-completion boundary — setting it on a `counter` makes the number **disappear** once that time passes (the counter's own animation is driven by `ctx.time / scene_duration`, not by `start_at`/`end_at`). Use `start_at` only to delay the count-up. diff --git a/.claude/skills/rustmotion/rules/grid-card-height.md b/.claude/skills/rustmotion/rules/grid-card-height.md index 42ea964..2d029be 100644 --- a/.claude/skills/rustmotion/rules/grid-card-height.md +++ b/.claude/skills/rustmotion/rules/grid-card-height.md @@ -1,26 +1,10 @@ -# Rule: Grid Cards Need Explicit Container Height +# Rule: Grid Containers — `height: "auto"` Sizes to Content (Verified) -Grid cells stretch to fill their row height. When the grid container has `height: "auto"`, rows expand to consume all available space from the parent flex layout, making cards much taller than their content. +**Update:** the stretching behavior this rule used to warn about is **not reproducible on the current build**. Rendered directly: a `card` with `display: "grid"`, `height: "auto"`, `grid-template-columns: ["1fr","1fr","1fr"]`, and **no** `grid-template-rows` — with children that have no explicit height either — produces a grid correctly sized to its content (each row matches its tallest cell), not a grid stretched to fill the scene. Same result whether the cell children carry explicit heights or are left to their own intrinsic size. If you hit stretching on a specific scenario, treat it as a new bug to investigate rather than expected behavior — it doesn't reproduce from `height: "auto"` alone. -**Always set an explicit height on grid containers** and use `grid-template-rows` to control row sizes. +**`height: "auto"` on a grid container is safe** — you do not need an explicit height just to prevent stretching. -**GOOD:** -```json -{ - "type": "card", - "style": { - "width": 1200, - "height": 400, - "display": "grid", - "grid-template-columns": ["1fr", "1fr", "1fr"], - "grid-template-rows": ["1fr", "1fr"], - "gap": 24 - }, - "children": [ ... ] -} -``` - -**BAD** (cards stretch to fill scene height): +**GOOD — auto height, content-sized rows (no `grid-template-rows` needed for this):** ```json { "type": "card", @@ -31,19 +15,22 @@ Grid cells stretch to fill their row height. When the grid container has `height "grid-template-columns": ["1fr", "1fr", "1fr"], "gap": 24 }, - "children": [ ... ] + "children": [ + { "type": "card", "style": { "background": "#1E293B", "border-radius": 12, "padding": 24 }, "children": [{ "type": "text", "content": "A", "style": { "font-size": 32, "color": "#FFFFFF" } }] }, + { "type": "card", "style": { "background": "#1E293B", "border-radius": 12, "padding": 24 }, "children": [{ "type": "text", "content": "B", "style": { "font-size": 32, "color": "#FFFFFF" } }] }, + { "type": "card", "style": { "background": "#1E293B", "border-radius": 12, "padding": 24 }, "children": [{ "type": "text", "content": "C", "style": { "font-size": 32, "color": "#FFFFFF" } }] } + ] } ``` -## Why This Happens +## When `grid-template-rows` is still useful -1. Scene flex layout gives the grid container all remaining vertical space -2. Without `grid-template-rows`, rows default to equal shares of that space -3. Grid cells stretch to fill their assigned row height -4. Child `height: "auto"` is ignored — the grid cell size wins +Not to prevent stretching (auto-height already avoids that), but when you want **explicit control** over row proportions instead of each row sizing independently to its tallest cell: +- Forcing all rows to the *same* height regardless of content (e.g. `grid-template-rows: ["1fr", "1fr"]` on an explicit-height container so two rows split evenly even if one row's content is shorter). +- A fixed pixel height for a specific row (e.g. `["200px", "auto"]`). ## Tips -- Calculate container height: `(row_count × estimated_card_height) + ((row_count - 1) × gap) + (padding × 2)` -- Use `grid-template-rows` with `"1fr"` entries to distribute rows evenly within the explicit height -- For single-row grids, a flex row with `"flex-direction": "row"` may be simpler +- If you *do* set an explicit `height` on the grid container (e.g. to force uniform row heights), calculate it as: `(row_count × target_row_height) + ((row_count - 1) × gap) + (padding × 2)`. +- Grid cells still need sizeable content: the 23 component types with no intrinsic measurer (`stat`, `chart`, `gauge`, `sparkline`, etc. — see [card-flex-layout.md](card-flex-layout.md)) need their own explicit `width`/`height` regardless of the grid container's height mode, or they render at 0×0 inside their cell. +- For single-row grids, a flex row with `"flex-direction": "row"` may be simpler. diff --git a/.claude/skills/rustmotion/rules/responsive-device-sizing.md b/.claude/skills/rustmotion/rules/responsive-device-sizing.md index 1d6d983..a1944bb 100644 --- a/.claude/skills/rustmotion/rules/responsive-device-sizing.md +++ b/.claude/skills/rustmotion/rules/responsive-device-sizing.md @@ -86,6 +86,8 @@ Reference: Tailwind CSS default spacing (4px base unit). ### Desktop Landscape (1920×1080) +> This table (and the Tailwind scale above, which tops out at `text-9xl` = 192px desktop) is sized for UI/dashboard-style content — cards, KPIs, subtitles. It does **not** apply to the brutalist/statement register (1600.agency / Machina, Aikido-style dark-premium), which runs hero/statement type at 200–1100px on this same 1920×1080 frame against 30–56px labels — an 6×–20× hierarchy ratio this table's progression doesn't reach. For that register, use the sizes and tracking in [1600-brutalist-style.md](1600-brutalist-style.md) and the "Statement register" section of [typography-readability.md](typography-readability.md) instead of scaling up from this table. + | Element | Size | Tailwind equivalent | |---|---|---| | **Title text** | font-size: 54–72 | `text-4xl` to `text-5xl` | @@ -162,10 +164,10 @@ Sizing rules above are guidelines — `rustmotion validate` is the source of tru "type": "card", "style": { "width": 920, "height": "auto", "flex-direction": "row" }, "children": [ - { "style": { "width": 180 } }, - { "style": { "width": 180 } }, - { "style": { "width": 180 } }, - { "style": { "width": 180 } } + { "type": "div", "style": { "width": 180, "height": 180 } }, + { "type": "div", "style": { "width": 180, "height": 180 } }, + { "type": "div", "style": { "width": 180, "height": 180 } }, + { "type": "div", "style": { "width": 180, "height": 180 } } ] } ``` @@ -182,8 +184,8 @@ Sizing rules above are guidelines — `rustmotion validate` is the source of tru "type": "card", "style": { "width": 1000, "height": "auto", "flex-direction": "row", "align-items": "center", "gap": 24 }, "children": [ - { "type": "icon", "style": { "width": 72, "height": 72 } }, - { "type": "text", "style": { "font-size": 48 } } + { "type": "icon", "icon": "lucide:check-circle", "style": { "width": 72, "height": 72 } }, + { "type": "text", "content": "Feature enabled", "style": { "font-size": 48 } } ] } ] diff --git a/.claude/skills/rustmotion/rules/stat-cards.md b/.claude/skills/rustmotion/rules/stat-cards.md index 7469f25..50670db 100644 --- a/.claude/skills/rustmotion/rules/stat-cards.md +++ b/.claude/skills/rustmotion/rules/stat-cards.md @@ -1,8 +1,10 @@ # Stat / KPI Cards Best Practices -## Use `stat` for dashboard KPIs +## `stat` requires explicit `width`/`height` — it has no intrinsic size -The `stat` component combines value + label + trend + sparkline in one card. Always wrap with a background via `style.background` for visual separation. +`stat` has **no intrinsic sizing** (verified against `crates/rustmotion-components/src/box_builder.rs`: `Stat` is absent from both `component_intrinsic` and `apply_intrinsic_overrides`, unlike `counter`/`text`/`badge`). Without an explicit `style.width`/`style.height`, a `stat` in a flex/grid layout lays out at 0×0 and paints nothing. + +**Measured:** three `stat`s placed in a flex-row card with no explicit size render **zero pixels** — not a smaller-than-expected card, literally nothing on screen. Always set `style.width`/`style.height` explicitly: ## GOOD: Stat card with all features ```json @@ -16,6 +18,21 @@ The `stat` component combines value + label + trend + sparkline in one card. Alw "style": { "width": 280, "height": 180, "background": "#1E293B", "border-radius": 16 } } ``` +This only works because `width`/`height` are set explicitly — that is a **requirement**, not a stylistic choice. + +## BAD: stat with no explicit size in a flex row — renders nothing +```json +{ + "type": "card", + "style": { "flex-direction": "row", "gap": 24 }, + "children": [ + { "type": "stat", "value": "45.2K", "label": "Active Users", "style": { "background": "#1E293B" } }, + { "type": "stat", "value": "12.8%", "label": "Growth", "style": { "background": "#1E293B" } }, + { "type": "stat", "value": "3.4s", "label": "Load time", "style": { "background": "#1E293B" } } + ] +} +``` +No `width`/`height` on any `stat` → all three collapse to 0×0. `rustmotion validate` does not catch this (it's a zero-size box, not an overflow) — the scene simply renders blank where the stats should be. ## Trend direction & color @@ -23,16 +40,17 @@ The `stat` component combines value + label + trend + sparkline in one card. Alw - `"down"` → red arrow by default (negative metric) - Override with `"color": "#22C55E"` when direction is good (e.g. churn **decreasing**) -## BAD: counter inside card for KPIs -```json -{ "type": "card", "children": [{ "type": "counter" }, { "type": "text" }] } -``` -Counter has centering bugs inside cards. Use `stat` instead which handles layout internally. +## `stat` vs `counter` — they are not interchangeable + +- `counter` **animates** its number over time (`from` → `to`, with `easing`). It centers correctly inside a card — verified: with `text-align: center`, its ink centre lands exactly on the content-box centre, both standalone and inside a card. The one real constraint is sizing the parent to its worst-case digit width (see [counter-standalone.md](counter-standalone.md)) — not centering. +- `stat` is a **static** composite (`value` is a plain string — there is no animated count-up). Use it when you want value + label + trend arrow + sparkline bundled in one box and don't need the number itself to animate. Use `counter` + `text` when the number needs to count up/down, or when you don't need trend/sparkline. + +Neither is a drop-in replacement for the other; pick based on whether the number animates and whether you need the trend/sparkline extras. ## Dashboard layout pattern -Place 3-4 stat cards in a row with absolute positioning, 320px apart: +Place 3-4 stat cards in a row with absolute positioning, 320px apart — each still needs its own explicit size: ```json -{ "type": "stat", "position": "absolute", "x": 80, "y": 100, "style": { "width": 280, "height": 180 } }, -{ "type": "stat", "position": "absolute", "x": 400, "y": 100, "style": { "width": 280, "height": 180 } }, -{ "type": "stat", "position": "absolute", "x": 720, "y": 100, "style": { "width": 280, "height": 180 } } +{ "type": "stat", "value": "45.2K", "label": "Active Users", "position": "absolute", "x": 80, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } }, +{ "type": "stat", "value": "12.8%", "label": "Growth", "position": "absolute", "x": 400, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } }, +{ "type": "stat", "value": "3.4s", "label": "Load time", "position": "absolute", "x": 720, "y": 100, "style": { "width": 280, "height": 180, "background": "#1E293B" } } ``` diff --git a/.claude/skills/rustmotion/rules/typography-readability.md b/.claude/skills/rustmotion/rules/typography-readability.md index f6cab56..45af8cf 100644 --- a/.claude/skills/rustmotion/rules/typography-readability.md +++ b/.claude/skills/rustmotion/rules/typography-readability.md @@ -16,19 +16,57 @@ All values are **rustmotion render pixels** (absolute, not CSS). Cross-reference Below these values, text renders as an illegible smudge on the target device. These floors correspond to ~12pt minimum on-screen, the accepted minimum for body copy readability. +These are **floors**, not caps. The table above (and the desktop scale table in [responsive-device-sizing.md](responsive-device-sizing.md)) is sized for dashboard/UI-style content. It does not apply to the statement/display register below — see [1600-brutalist-style.md](1600-brutalist-style.md) for that register's own sizing. + +## Statement register: hierarchy, scale, tracking + +The reference films (1600.agency/Machina, Aikido-style dark-premium) use a much larger, flatter hierarchy than typical UI text, with a hard split between **display** type (huge, tight leading) and **labels** (small, normal leading) and almost nothing in between. Measured from the two shipped desktop (1920×1080) templates: + +| Role | `examples/1600-style.json` (brutalist) | `examples/dark-premium.json` (dark-premium) | +|---|---|---| +| Hero / background numeral | 620–1100px | 320px | +| Statement / title lines | 118–300px | 64–168px | +| Kicker / label | 32–56px | 44–46px | +| `line-height` on display type | 0.80–1.02 | 1.04 (uniform) | +| `letter-spacing` on display type | `+1` to `+4` (big titles), `+4` to `+10` (small kickers) | `-2` (uniform, all sizes) | + +The ratio between hero and label size is roughly **6×–20×** — far beyond a conventional type scale (Tailwind's `text-9xl` → `text-sm` ratio is ~9×, and its desktop pixel ceiling is 192px, well under this register's 300–1100px hero range). Do not reach for the Tailwind scale in [responsive-device-sizing.md](responsive-device-sizing.md) for this register; go straight to the sizes above or to [1600-brutalist-style.md](1600-brutalist-style.md). + +### Tracking (letter-spacing) — two valid directions, not one + +`style.letter-spacing` accepts negative values with no restriction (`Length::Px` is a plain signed `f32`, `css/units.rs:138-151`) — negative tracking is fully supported by the engine, not just tolerated. + +- **Brutalist / uppercase grotesque** (1600.agency register): **positive** tracking, `+1`–`+2` on huge titles, `+4`–`+10` on small kickers (proportionally more open at small sizes). See [1600-brutalist-style.md](1600-brutalist-style.md). +- **Dark-premium / tight-leading statement** (Aikido-style register): **negative** tracking, a uniform `-2` applied at every display size from 64px to 320px, paired with the tight `1.04` line-height above. This is the register `dark-premium.json` uses throughout — negative tracking at scale is a legitimate, deliberate choice, not an oversight. + +Pick one tracking direction per scenario and apply it consistently — don't mix positive and negative tracking across scenes in the same video. + ## Line height -For text that spans 2 or more lines, always set: +The correct range depends on register, and the two registers below are **both legitimate** — there is no engine-level floor on `line-height` (it is a plain `f32` multiplier with no validation, `css/style.rs:69`), so nothing stops either one from rendering correctly. + +### Body / paragraph copy — 1.4 to 1.6 + +For multi-line **reading** text (body copy, descriptions, bullet lists) always set: ```json { "style": { "line-height": 1.5 } } ``` -Acceptable range: `1.4` to `1.6`. Outside this range: -- `< 1.2` — lines collide, text becomes unreadable -- `> 2.0` — too airy for content-dense scenes +Acceptable range: `1.4` to `1.6`. Below `1.2`, wrapped paragraph lines visually collide at body sizes; above `2.0` it reads as too airy for content-dense scenes. Single-line headings and labels can omit `line-height` (the default is fine). + +### Display / statement type — 0.80 to 1.05 (tight, deliberate) + +For large **display** type (titles, hero numbers, stacked kinetic-typography lines — anything set at ~120px or above where lines are a designed block, not run-in prose), tight leading is the target, not a defect. This is the single most identifiable trait of the reference register (1600.agency / Machina, Aikido-style dark-premium): measured directly from the two shipped templates — + +| Template | `line-height` | `font-size` range used at that leading | +|---|---|---| +| `examples/1600-style.json` | `0.80`–`1.02` | 118–1100px | +| `examples/dark-premium.json` | `1.04` (uniform) | 64–320px | + +Use `0.92`–`1.02` as the default tight-leading range for stacked display lines; go as low as `0.80` for single giant background numerals/words where lines never wrap. See [1600-brutalist-style.md](1600-brutalist-style.md) for the full recipe. -Single-line headings and labels can omit `line-height` (the default is fine). +**Precedence:** when a scene is authored in the brutalist/statement register, its display-type `line-height` follows this section (and [1600-brutalist-style.md](1600-brutalist-style.md)), not the 1.4–1.6 body-copy range above. Both ranges can appear in the same scenario — a hero title at `0.95` and a caption line at `1.5` are not in conflict; they're different roles. ## Contrast rules (hard rules, not guidelines)