diff --git a/crates/op-ai-skills/skills/domains/dashboard.md b/crates/op-ai-skills/skills/domains/dashboard.md index c797047bc..13d80a3a8 100644 --- a/crates/op-ai-skills/skills/domains/dashboard.md +++ b/crates/op-ai-skills/skills/domains/dashboard.md @@ -5,7 +5,7 @@ phase: [generation] trigger: keywords: [dashboard, admin, analytics, data, table, 仪表盘, 后台, 数据表, 报表] priority: 28 -budget: 1800 +budget: 2300 category: domain --- @@ -16,8 +16,8 @@ A data-dense product surface. The always-on principles (purpose-first, dominant ZONE STRUCTURE (adapt — do not force sidebar+table when the purpose differs): - Root: width=1200-1440, layout="horizontal" — Sidebar + Main. -- Sidebar: width=240-280, height="fill_container", layout="vertical", justifyContent="space_between". HARD ARCHETYPE RULE: a sidebar is a VERTICAL rail (brand block, stacked nav items, footer profile) — NEVER a horizontal navbar, NEVER a hero headline or marketing copy. FOOTER-SINK CONTRACT (pins the user/account card to the very BOTTOM, not floating mid-rail): the sidebar column MUST be height="fill_container" — a "fit_content" column hugs its content so space_between has no free space to distribute and nothing sinks — AND have EXACTLY TWO children: a TOP group {brand, nav groups} and a BOTTOM group {user/account/settings}. NEVER list brand + nav + footer as flat siblings under space_between (it then spreads ALL of them evenly and the nav floats into the middle). Brand top (padding=[24,16]); nav groups with section labels (12px uppercase, muted, letterSpacing≈1). Nav item: frame(horizontal, gap=12, alignItems="center", padding=[10,16]) > icon_font(18-20) + text(14). Active: accent surface tint OR a left accent bar — never both. icon + label always (never icon-only nav). ONE rhythm between brand and nav: a single TOP-group gap of 32-48 — never stack brand padding-bottom + a divider row + the group gap (each interval re-applies the gap; measured 121px of dead rail vs the intended 48). If you insert a divider between brand and nav, drop the group gap to 12-16. -- Main: width="fill_container", height="fill_container", layout="vertical", padding=[24,32], gap=24-28. This is the deliberate page-level work-surface exception inside a definite-height horizontal dashboard shell; sections inside Main still Hug Height. +- Sidebar: width=240-280, height="fill_container", layout="vertical", justifyContent="space_between". HARD ARCHETYPE RULE: a sidebar is a VERTICAL rail (brand block, stacked nav items, footer profile) — NEVER a horizontal navbar, NEVER a hero headline or marketing copy. FOOTER-SINK CONTRACT (pins the user/account card to the BOTTOM): the sidebar column MUST be height="fill_container" ("fit_content" hugs content so space_between has nothing to distribute) AND have EXACTLY TWO children: TOP group {brand, nav groups}, BOTTOM group {user/account/settings}. NEVER flat brand+nav+footer siblings under space_between — spreads them evenly, nav floats mid-rail. Brand top (padding=[24,16]); nav groups with section labels (12px uppercase, muted, letterSpacing≈1). Nav item: frame(horizontal, gap=12, alignItems="center", padding=[10,16]) > icon_font(18-20) + text(14). Active: accent surface tint OR a left accent bar — never both. Icon + label always (never icon-only). ONE rhythm between brand and nav: single TOP-group gap 32-48 — never stack brand padding-bottom + divider row + group gap (each interval re-applies the gap, ballooning dead rail). A divider between brand and nav → drop the group gap to 12-16. +- Main: width="fill_container", height="fill_container", layout="vertical", padding=[24,32], gap=24-28 — the deliberate page-level work-surface exception inside a definite-height horizontal dashboard shell; sections inside Main still Hug Height. - Top bar: height=56-64, padding=[0,24], horizontal, justifyContent="space_between". Left: page title / breadcrumbs. Right: search + bell + avatar. METRIC ROW: @@ -27,36 +27,36 @@ METRIC ROW: CHARTS: - Card with header (title left + period/filter control right) then plot area. Charts row = 2 equal columns, gap=24. One insight per chart. -- Plot area = SIMPLE REAL BAR CHART by default, NOT an empty placeholder. Draw it with flex so bars auto-distribute and share a baseline — NEVER use layout="none" or manual x/y for bars (the model mis-computes those and the chart collapses). +- Plot area = SIMPLE REAL BAR CHART by default, NOT an empty placeholder. Draw with flex so bars auto-distribute and share a baseline — NEVER `layout="none"` or manual x/y for bars (the model mis-computes those and the chart collapses). - Plot container: frame, layout="horizontal", alignItems="end", justifyContent="space_between" OR gap=8-12, height=120-160, width="fill_container". -- Bars: 7-12 periods. Each bar is a frame/rect with width="fill_container" (equal) OR fixed 10-18, VALUE-PROPORTIONAL height (vary values e.g. 48/72/60/96/84/120/68); never all equal, never all fill. Top-only cornerRadius≈4; peak bar uses accent, the rest use muted surface. +- Bars: 7-12 periods. Each bar is a frame/rect with width="fill_container" (equal) OR fixed 10-18, VALUE-PROPORTIONAL height (vary values e.g. 48/72/60/96/84/120/68); never all equal, never all fill. Top-only cornerRadius≈4; peak bar uses accent, rest use muted surface. - Under bars: x-axis label row aligned to bars; month/day abbreviations, 11px muted. - Keep charts as bar charts unless explicitly asked otherwise. If using line/area, still avoid absolute positioning; flex bars are the default. -- DONUT / RING anatomy (arc ellipses): ALL arc segments + the center label live in ONE fixed square wrapper frame with layout="none" and explicit x/y so they stack CONCENTRICALLY — arc ellipses left in a flex row lay out side by side and the donut falls apart. Each arc x/y = (wrapper - arc)/2. Because absolute-stack children are front-to-back, order center content first, then progress arc, then track; never put the track above the progress. Center value text uses an explicitly positioned child frame centered by the same formula. -- Legend rows: dot(8) + label + value with gap 6-8 between label and value — never butt "Solar" against "44%". -- Chart tooltip (the floating value callout): a SURFACE — fill $color-surface + hairline stroke + cornerRadius 6 + padding [8,10]; a fill-less tooltip paints bare text over the plot lines. -- Status pill in a table cell: the text lives INSIDE the pill frame (pill > text), never beside it — a childless tinted pill collapses to a smear with the label floating outside. +- DONUT / RING: same concentric `layout="none"` stack as the `shapes-and-decks` worked example (center content, then progress arc, then track — front-to-back); arc ellipses left in a flex row lay out side by side and the donut falls apart. +- Legend rows: dot(8) + label + value, gap 6-8 between label and value — never butt "Solar" against "44%". +- Chart tooltip (floating value callout): a SURFACE — fill $color-surface + hairline stroke + cornerRadius 6 + padding [8,10]; a fill-less tooltip paints bare text over the plot lines. +- Status pill in a table cell: text lives INSIDE the pill frame (pill > text), never beside it — a childless tinted pill collapses to a smear with the label floating outside. DATA TABLES (use only when no predefined Table component exists): -- STRICT hierarchy: Table(frame, vertical) > Row(frame, horizontal, width="fill_container") > Cell(frame) > content. Each CELL is its own frame — NEVER put content directly in a row, or columns won't align. Header + every body row repeat the IDENTICAL cell widths. +- STRICT hierarchy: Table(frame, vertical) > Row(frame, horizontal, width="fill_container") > Cell(frame) > content. Each CELL is its own frame — NEVER put content directly in a row, or columns won't align. Header + every body row repeat IDENTICAL cell widths. - Column width by role: identifier 200-250; email/title "fill_container"; status/badge 100-120; date 120-150; number/amount 90-120; actions 80-100. - Column alignment by data type: text/labels left; numbers/amounts/dates RIGHT (tabular figures for clean decimal alignment); status badge + row actions centered. Header label alignment matches its column. - Header row: distinct treatment (subtle fill or bottom border), bold 12-13px, often uppercase muted, padding=[12,16], height fixed. -- Body rows: padding=[12,16] (compact) or [10,16); separate with a 1px bottom divider (stroke={"thickness":{"bottom":1}}, hairline color, omit on the LAST row) OR a very subtle alternating row tint — pick ONE, never both. Design the hover and selected row state (tint), not just the default. -- ROWS TOUCH: the Table frame itself has gap=0 and padding=0 — rhythm comes ONLY from each row's own padding + the hairline/tint. NEVER put a gap between rows (gap=16 turns the table into floating stripes with page background bleeding through) and NEVER wrap rows in a padded container (the row's horizontal padding IS the table inset). Round the TABLE frame (cornerRadius 8-12 + clipContent) instead of rounding rows. +- Body rows: padding=[12,16] (compact) or [10,16]; separate with a 1px bottom divider (stroke={"thickness":{"bottom":1}}, hairline color, omit on the LAST row) OR a subtle alternating row tint — pick ONE, never both. Design the hover and selected row state (tint), not just the default. +- ROWS TOUCH: the Table frame has gap=0 and padding=0 — rhythm comes ONLY from each row's own padding + the hairline/tint. NEVER a gap between rows (gap=16 turns the table into floating stripes with page bg bleeding through) and NEVER wrap rows in a padded container (the row's horizontal padding IS the table inset). Round the TABLE frame (cornerRadius 8-12 + clipContent) instead of rounding rows. - Cell content beyond text: status badge (pill, cornerRadius=full, semantic color: green active / amber pending / red error / muted neutral), avatar+name pair, small action buttons/icons. Identifier cell may stack a primary line + muted secondary line. -- Row actions: trailing cell — 1-2 icon buttons inline (edit/delete) OR a single more-vertical overflow when 3+; do not spray every action across the row. -- Below the table: a footer row with result count + pagination (prev/page-numbers/next). Design the EMPTY state (icon + one-line message + primary CTA) and the LOADING state (skeleton rows mirroring the column widths) — a data table without its empty/loading state is incomplete. -- Responsive: a wide multi-column table does NOT survive a narrow screen — at mobile widths replace the table with a stacked list of CARDS (one card per record, label:value pairs), not a shrunken table. -- No user data → generate realistic dummy values per cell; 4-7 columns is a sane default — but ONLY when the table owns the full content width. In a master-detail split (list pane + detail panel), the LIST PANE gets compact rows (avatar + name/email stack + one meta + status), NEVER a 5-6 column table: five text columns need ≥600px of cell space and a half-width pane cannot fit them. +- Row actions: trailing cell — 1-2 icon buttons inline (edit/delete) OR a single more-vertical overflow when 3+; never spray every action across the row. +- Below the table: a footer row with result count + pagination (prev/page-numbers/next). Design the EMPTY state (icon + one-line message + primary CTA) and LOADING state (skeleton rows mirroring column widths) — a table without empty/loading states is incomplete. +- Responsive: a wide multi-column table does NOT survive a narrow screen — at mobile widths replace it with stacked CARDS (one per record, label:value pairs), not a shrunken table. +- No user data → generate realistic dummy values; 4-7 columns is a sane default — but ONLY when the table owns full content width. In a master-detail split, the LIST PANE gets compact rows (avatar + name/email stack + one meta + status), NEVER a 5-6 column table: five text columns need ≥600px and a half-width pane cannot fit them. SPACING (reference-measured constants — copy these, do not improvise): - Content zone: section gap 28-48 by density (airy/luxury 48, balanced 32, dense 28); content padding [32-48, 40-56]. Inside a section, title→body gap 16-24. -- KPI/metric cards: row gap 20; card = vertical, padding 24-28, gap 20, width fill_container, height fit_content by default. Use Full Height only when the row explicitly calls for equal-height cross-axis stretch and has a definite row height; do not infer it from one wrapping label. Card surface: hairline stroke (1px, one step above background) OR a fill — never both, never thick borders. Value 34-40px display font (letterSpacing -1), label 11-12px uppercase muted, change row gap 8. The change chip goes on its OWN line BELOW the value (card vertical: header row / value / change) — never beside a 34-40px value in a ~200px card, it cannot fit; the chip hugs (fit_content), never fill_container. +- KPI/metric cards: row gap 20; card = vertical, padding 24-28, gap 20, width fill_container, height fit_content by default (Full Height only for explicit equal-height cross-axis stretch with a definite row height — not inferred from one wrapping label). Card surface: hairline stroke (1px, one step above bg) OR a fill — never both, never thick borders. Value 34-40px display font (letterSpacing -1), label 11-12px uppercase muted, change row gap 8. The change chip goes on its OWN line BELOW the value (header row / value / change) — never beside a 34-40px value in a ~200px card, it cannot fit; the chip hugs (fit_content), never fill_container. - Nav count badge (the "12" on a menu item): pill — cornerRadius=full, padding [2,8], 11px semibold, accent tint fill; never a bare square. -- Section wrapper rows (the metrics strip, card rows, chart+list splits) carry NO padding of their own — the content column's padding is the single horizontal inset; padding belongs to CARDS. A padded transparent wrapper misaligns section edges and starves its children. +- Section wrapper rows (metrics strip, card rows, chart+list splits) carry NO padding of their own — the content column's padding is the single horizontal inset; padding belongs to CARDS. A padded transparent wrapper misaligns section edges and starves its children. - Filter chips / small buttons: padding [10,16], icon 14 + text 12 weight 500, inner gap 10, chips row gap 12. - List (non-table) items: padding [20,24] + 1px bottom hairline, container hairline frame; list section gap 24. - Pagination: 32x32 squares, gap 4, active page accent fill. diff --git a/crates/op-ai-skills/skills/knowledge/horizontal-scroll-fallback.md b/crates/op-ai-skills/skills/knowledge/horizontal-scroll-fallback.md new file mode 100644 index 000000000..75ee9c272 --- /dev/null +++ b/crates/op-ai-skills/skills/knowledge/horizontal-scroll-fallback.md @@ -0,0 +1,88 @@ +--- +name: horizontal-scroll-fallback +description: Hand-built JSON structure for horizontal scroll rows (cards/chips/tiles) when MCP row tools are unavailable +phase: [generation] +trigger: + keywords: [horizontal scroll, scrolling cards, swipeable row, chip row, card row, metric tiles, 横向滚动, 滑动列表] +priority: 25 +budget: 1200 +category: knowledge +--- + +HORIZONTAL SCROLL ROW — HAND-BUILT JSON FALLBACK + +ONLY use this when the MCP row tools (`add_card_row_v0` / `add_metric_row_v0` / `add_nav_chip_row_v0`, taught by the `overflow` skill) are unavailable — embedded AI flow / JSON-only output. Generate EXACTLY this structure; do NOT just emit 6 cards inside a horizontal layout, the children will spill outside the page frame. + +Structure: + +- A wrapper frame with `width="fill_container"`, `height="fit_content"`, `layout="vertical"`, `clipContent=true`. +- Inside it, a row frame with `width="fit_content"`, `height="fit_content"`, `layout="horizontal"`, `gap=12`, `padding=[0,20]`. +- The row frame holds the actual cards. + +Every **content / product / workout card** in the row MUST: + +- Have a FIXED numeric `width` (typically 120-160 for mobile, 200-260 for desktop). Never `fill_container`, never `fit_content` - fixed pixels. +- Share identical width with its siblings for visual rhythm. + +**EXCEPTION — nav chips / category chips / filter tags** (icon + short label like "All" / "Pizza" / "Videos"): use `width="fit_content"`, NEVER a fixed 120-160. That fixed width is content-card sizing; a 6-chip category row at 132px each becomes ~800px and scrolls off-screen for what should comfortably fit on one screen. With `fit_content`, a handful of short chips sit on one row (no scroll), and only a genuinely long list scrolls. Keep the same clipContent wrapper + fit_content row — just let each chip hug its content (icon + label + small horizontal padding). + +**COUNT CAP for a no-scroll chip row (mobile 375px):** even at `fit_content`, only ~4-5 icon+label chips fit one phone width. For primary mobile category navigation, prefer the top 4 fully visible chips or wrap/grid them — do NOT show a half-clipped fifth chip as decoration. If the design is meant to fit on screen WITHOUT horizontal scrolling, emit only the chips that fit — do NOT pack 6+ chips into the row, the extras render off the right edge of the device. If you genuinely need all categories, you MUST place the row inside the `clipContent` wrapper above so the overflow clips at the screen edge (scroll row) instead of spilling past the phone frame. A bare horizontal frame with 6+ chips and no `clipContent` ancestor is the #1 mobile overflow bug — never emit it. + +Example - 6 workout cards inside a 375px-wide mobile page: + +```json +{ + "id": "cards-scroll", + "type": "frame", + "name": "Workouts Scroll", + "width": "fill_container", + "height": "fit_content", + "layout": "vertical", + "clipContent": true, + "children": [ + { + "id": "cards-row", + "type": "frame", + "name": "Workouts Row", + "width": "fit_content", + "height": "fit_content", + "layout": "horizontal", + "gap": 12, + "padding": [0, 20], + "children": [ + { + "id": "card-hiit", + "type": "frame", + "width": 140, + "height": 160, + "cornerRadius": 20, + "layout": "vertical", + "gap": 8, + "padding": 16, + "fill": [{ "type": "solid", "color": "#1a1a1a" }], + "children": [] + }, + { + "id": "card-strength", + "type": "frame", + "width": 140, + "height": 160, + "cornerRadius": 20, + "layout": "vertical", + "gap": 8, + "padding": 16, + "fill": [{ "type": "solid", "color": "#1a1a1a" }], + "children": [] + } + ] + } + ] +} +``` + +Anti-patterns (do NOT emit any of these): + +- Putting 5+ cards directly inside a `layout="horizontal"` page-root frame (they overflow the phone width). +- Using `fill_container` on cards in a horizontal row (they squish down to invisibility). +- Using `width="fit_content"` on **content/product cards** - text-driven widths are unpredictable and break rhythm. (Nav / category chips are the EXCEPTION above — those SHOULD use fit_content so a short row fits one screen.) +- Skipping the `clipContent=true` wrapper and relying on Skia to clip (it doesn't — only `clipContent:true` enables clipping). diff --git a/crates/op-ai-skills/skills/knowledge/shader-fill.md b/crates/op-ai-skills/skills/knowledge/shader-fill.md index 841882c37..48580ec70 100644 --- a/crates/op-ai-skills/skills/knowledge/shader-fill.md +++ b/crates/op-ai-skills/skills/knowledge/shader-fill.md @@ -5,7 +5,7 @@ phase: [generation] trigger: keywords: [shader, glsl, sksl, generative, noise, aurora] priority: 28 -budget: 700 +budget: 750 category: knowledge --- @@ -14,11 +14,12 @@ SHADER FILL (advanced — render-only): A node fill can be a native SkSL shader. Use it only for genuinely procedural surfaces (generative noise, aurora, animated-looking glow) where `linear_gradient` / `radial_gradient` / `mesh_gradient` can't get -the look. For ordinary multi-hue panels, PREFER `mesh_gradient` — it is -simpler, safer, and renders everywhere. WHEN UNSURE, fall back to -`linear_gradient` or `mesh_gradient`. A shader that fails to compile -degrades to a flat solid (first colour uniform, else gray), so a bad -shader silently looks worse than a gradient. +the look. For ordinary multi-hue panels PREFER `mesh_gradient` — simpler, +safer, renders everywhere. WHEN UNSURE, fall back to `linear_gradient` or +`mesh_gradient`. A shader that fails to compile degrades to a flat solid +(first colour uniform, else gray) — a bad shader silently looks worse +than a gradient. Do NOT hand-author exotic shaders for routine UI; +reach for one only on explicit "generative/noise/aurora/shader" intent. SHAPE (one fill entry): @@ -26,21 +27,20 @@ SHAPE (one fill entry): fill: [{ type: "shader", sksl: "", uniforms: { name: value, ... } }] ``` -- `sksl` — RAW SkSL (Skia's GLSL dialect). The entrypoint MUST be the - exact signature `half4 main(float2 fragCoord)` and return a `half4` - RGBA colour. `fragCoord` is the pixel position inside the node box. -- `uniforms` — OPTIONAL map of named uniforms. A shader may take none. - Supported value types: - - number → `float` uniform (declare `uniform float name;`) - - number array `[a,b]` / `[a,b,c]` / `[a,b,c,d]` → `vec2`/`vec3`/`vec4` - - hex string `"#rrggbb"` → `vec4` colour (premultiplied RGBA); declare - it `uniform half4 name;`. The first colour uniform also doubles as - the visible fallback if compilation fails — so always include one. +- `sksl` — RAW SkSL (Skia's GLSL dialect). Entrypoint MUST be the exact + signature `half4 main(float2 fragCoord)`, returning a `half4` RGBA + colour. `fragCoord` is the pixel position inside the node box. +- `uniforms` — OPTIONAL map of named uniforms (a shader may take none): + number → `float`; number array `[a,b]`/`[a,b,c]`/`[a,b,c,d]` → + `vec2`/`vec3`/`vec4`; hex string `"#rrggbb"` → `vec4` colour + (premultiplied RGBA, declare `uniform half4 name;`) — the first colour + uniform also doubles as the visible fallback if compilation fails, so + always include one. - Optional `opacity` (0..1) folds into the fill alpha. KNOWN-GOOD SNIPPETS (copy verbatim, tweak colours via uniforms): -1) Vertical two-colour fade (top → bottom): +1) Vertical fade (top → bottom): ``` fill: [{ type: "shader", @@ -71,6 +71,3 @@ RULES: - Pass the node's pixel size as a `float2 size` uniform when you need to normalise `fragCoord` (SkSL has no built-in resolution). - No external textures / images in v1 — colours + math only. -- Do NOT hand-author exotic shaders for routine UI. Reach for a shader - only on explicit "generative / noise / aurora / shader" intent; - otherwise `mesh_gradient` or a gradient is the right tool. diff --git a/crates/op-ai-skills/skills/phases/generation/interactivity.md b/crates/op-ai-skills/skills/phases/generation/interactivity.md index d727dc606..83dcfc962 100644 --- a/crates/op-ai-skills/skills/phases/generation/interactivity.md +++ b/crates/op-ai-skills/skills/phases/generation/interactivity.md @@ -37,15 +37,15 @@ Add `"persist": true` to survive app restarts: ``` CROSS-SECTION SHARED STATE — use `$app.*` (document-root state) for values -that two or more independently-generated sections must read or write. A -counter button in one section and a display label in another section both -reach `$app.count` without coupling their node trees. Per-section private -values may use `$state.*`. When in doubt, prefer `$app.*`. +two or more independently-generated sections must read or write (e.g. a +counter button in one section and a display label in another both reach +`$app.count` without coupling their node trees). Per-section private values +may use `$state.*`. When in doubt, prefer `$app.*`. BINDINGS — declarative reads (`bind:value`, `content`): -`bindings` is a map from property name to an expression string. Use it to -keep node properties in sync with state automatically. +`bindings` maps a property name to an expression string, keeping node +properties in sync with state automatically. - Read-only bind: `"bindings": { "content": "\"Count: \" + $app.count" }` (the text node displays the live count, grounded in `full-jian-extensions.op:36`) @@ -72,15 +72,15 @@ Supported event hook keys (camelCase, `#[serde(rename_all = "camelCase")]`): Action vocabulary (body shape per action): -| Action | Body | Effect | -|-----------|------------------------------------------------------------------|------------------------------------------| -| `set` | `{ "": "" }` map of assignments | Write one or more state variables | -| `toggle` | `""` — the bool variable to flip | Toggle a bool state variable | -| `toast` | `""` string or template literal | Show a transient notification | -| `push` | `"\"\""` — a JSON string whose VALUE is itself `""`, quotes included | Drill into a screen (keeps the caller reachable via back/pop) | -| `replace` | `"\"\""` — same quote-literal shape as `push` | Switch to a sibling screen (tab bar / sidebar — no back entry) | -| `pop` | `null` — no body | Return to the previous screen | -| `if` | `{ "expr": "", "then": [...], "else": [...] }` | Conditional action branch (`else` optional) | +| Action | Body | Effect | +|---|---|---| +| `set` | `{ "": "" }` map of assignments | Write one or more state variables | +| `toggle` | `""` — the bool variable to flip | Toggle a bool state variable | +| `toast` | `""` string or template literal | Show a transient notification | +| `push` | `"\"\""` — a JSON string whose VALUE is itself `""`, quotes included | Drill into a screen (back/pop can return) | +| `replace` | `"\"\""` — same quote-literal shape as `push` | Switch to a sibling screen (tab bar / sidebar — no back entry) | +| `pop` | `null` — no body | Return to the previous screen | +| `if` | `{ "expr": "", "then": [...], "else": [...] }` | Conditional branch (`else` optional) | `push` / `replace` bodies compile as a Tier-1 EXPRESSION, not a literal path — an unquoted `/stats` lexes as a division token and fails to compile. Always wrap the route path in an extra pair of escaped quotes: `{ "push": "\"/stats\"" }`, never `{ "push": "/stats" }`. @@ -153,16 +153,15 @@ when tapped. PLACEMENT RULES: - Declare `state` on the **lowest common ancestor** node that all bindings / - event handlers need. For cross-section designs, declare on the document root. + event handlers need — the document root for cross-section designs. - `bindings` lives on the node whose property is driven (e.g. the `text` node whose `content` reflects a counter, or the `text_input` node whose `value` binds a form field). - `events` lives on the interactive node (button frame, input node, list - item) — NOT on a wrapper layout frame that has no tap/change semantics. + item) — NOT on a wrapper layout frame with no tap/change semantics. - Input nodes (`text_input`) use `bind:value` for two-way sync and - `onChange` to write `$event.value` back to state. Do not manually echo - `$event.value` into a display node — use a `bindings.content` expression - instead. + `onChange` to write `$event.value` back to state — don't manually echo + `$event.value` into a display node, use a `bindings.content` expression. CORRECTNESS CHECKLIST: diff --git a/crates/op-ai-skills/skills/phases/generation/overflow.md b/crates/op-ai-skills/skills/phases/generation/overflow.md index e2b2ab17b..cc6675360 100644 --- a/crates/op-ai-skills/skills/phases/generation/overflow.md +++ b/crates/op-ai-skills/skills/phases/generation/overflow.md @@ -19,102 +19,12 @@ OVERFLOW PREVENTION (CRITICAL): When the spec says "horizontal scrolling cards", "swipeable row", "chip row", "metric tiles", or similar, use ONE of the two paths below. -### Preferred (MCP tool path): pick one of 3 narrow row tools +**Preferred (MCP tool path)** — if you have MCP tools (external client: Claude Code / Codex / Cursor), call the tool matching what's in the row; all three produce the overflow-safe wrapper+clipContent+fit_content structure and cannot be malformed by schema: -If you have access to MCP tools (external client: Claude Code / Codex / Cursor), call the tool matching what's in the row — all three produce the overflow-safe wrapper+clipContent+fit_content structure and cannot be made incorrectly by schema. +- **`add_card_row_v0`** — items with `title` + optional `subtitle`/`icon` (workout/feature/content cards). Default 140x160. +- **`add_metric_row_v0`** — items with `label` + `value` + optional `icon` (dashboard stats). Default 120x100, value 28/700. +- **`add_nav_chip_row_v0`** — items with `label` + optional `icon`/`active` (plain-text filter tags OK). Default 72xfit_content. -- **`add_card_row_v0`** — items with `title` + optional `subtitle` + optional `icon` (workout cards, feature tiles, content cards). Default card size 140×160. -- **`add_metric_row_v0`** — items with `label` + `value` + optional `icon` (dashboard stats: Steps/Kcal/Sleep/Revenue). Default tile size 120×100, value rendered 28/700. -- **`add_nav_chip_row_v0`** — items with `label` + optional `icon` + optional `active` flag. Label-only chips supported (plain-text filter tags like "All / Videos / Photos"). Default chip size 72×fit_content. +Add per-tile fills/colors afterward with a separate `batch_design` U-op (these tools are style-guide orthogonal, ship colorless on purpose). -Example: - -``` -add_metric_row_v0({ - items: [ - { label: "Steps", value: "8,432", icon: "activity" }, - { label: "Kcal", value: "512", icon: "flame" }, - { label: "Sleep", value: "7h 24m", icon: "moon" }, - ], -}) -``` - -Add per-tile fills / colors afterwards with a separate `batch_design` U-op (these tools are style-guide orthogonal and ship colorless on purpose). - -### Fallback (hand-built JSON path) - -ONLY when the MCP tool is unavailable (embedded AI flow / JSON-only output), generate EXACTLY this structure — do NOT just emit 6 cards inside a horizontal layout, the children will spill outside the page frame. - -Structure: - -- A wrapper frame with `width="fill_container"`, `height="fit_content"`, `layout="vertical"`, `clipContent=true`. -- Inside it, a row frame with `width="fit_content"`, `height="fit_content"`, `layout="horizontal"`, `gap=12`, `padding=[0,20]`. -- The row frame holds the actual cards. - -Every **content / product / workout card** in the row MUST: - -- Have a FIXED numeric `width` (typically 120-160 for mobile, 200-260 for desktop). Never `fill_container`, never `fit_content` - fixed pixels. -- Share identical width with its siblings for visual rhythm. - -**EXCEPTION — nav chips / category chips / filter tags** (icon + short label like "All" / "Pizza" / "Videos"): use `width="fit_content"`, NEVER a fixed 120-160. That fixed width is content-card sizing; a 6-chip category row at 132px each becomes ~800px and scrolls off-screen for what should comfortably fit on one screen. With `fit_content`, a handful of short chips sit on one row (no scroll), and only a genuinely long list scrolls. Keep the same clipContent wrapper + fit_content row — just let each chip hug its content (icon + label + small horizontal padding). - -**COUNT CAP for a no-scroll chip row (mobile 375px):** even at `fit_content`, only ~4-5 icon+label chips fit one phone width. For primary mobile category navigation, prefer the top 4 fully visible chips or wrap/grid them — do NOT show a half-clipped fifth chip as decoration. If the design is meant to fit on screen WITHOUT horizontal scrolling, emit only the chips that fit — do NOT pack 6+ chips into the row, the extras render off the right edge of the device. If you genuinely need all categories, you MUST place the row inside the `clipContent` wrapper above so the overflow clips at the screen edge (scroll row) instead of spilling past the phone frame. A bare horizontal frame with 6+ chips and no `clipContent` ancestor is the #1 mobile overflow bug — never emit it. - -Example - 6 workout cards inside a 375px-wide mobile page: - -```json -{ - "id": "cards-scroll", - "type": "frame", - "name": "Workouts Scroll", - "width": "fill_container", - "height": "fit_content", - "layout": "vertical", - "clipContent": true, - "children": [ - { - "id": "cards-row", - "type": "frame", - "name": "Workouts Row", - "width": "fit_content", - "height": "fit_content", - "layout": "horizontal", - "gap": 12, - "padding": [0, 20], - "children": [ - { - "id": "card-hiit", - "type": "frame", - "width": 140, - "height": 160, - "cornerRadius": 20, - "layout": "vertical", - "gap": 8, - "padding": 16, - "fill": [{ "type": "solid", "color": "#1a1a1a" }], - "children": [] - }, - { - "id": "card-strength", - "type": "frame", - "width": 140, - "height": 160, - "cornerRadius": 20, - "layout": "vertical", - "gap": 8, - "padding": 16, - "fill": [{ "type": "solid", "color": "#1a1a1a" }], - "children": [] - } - ] - } - ] -} -``` - -Anti-patterns (do NOT emit any of these): - -- Putting 5+ cards directly inside a `layout="horizontal"` page-root frame (they overflow the phone width). -- Using `fill_container` on cards in a horizontal row (they squish down to invisibility). -- Using `width="fit_content"` on **content/product cards** - text-driven widths are unpredictable and break rhythm. (Nav / category chips are the EXCEPTION above — those SHOULD use fit_content so a short row fits one screen.) -- Skipping the `clipContent=true` wrapper and relying on Skia to clip (it doesn't — only `clipContent:true` enables clipping). +**Fallback (no MCP tools — embedded AI / JSON-only output)** — see the `horizontal-scroll-fallback` knowledge skill for the exact wrapper+row+card JSON structure, the fixed-vs-fit_content width rule per card type, and the anti-patterns to avoid. Do NOT freehand 5+ cards inside a bare horizontal frame — they overflow the phone width. diff --git a/crates/op-ai-skills/skills/phases/generation/variables.md b/crates/op-ai-skills/skills/phases/generation/variables.md index 9fc5d8af5..9dcc7f87e 100644 --- a/crates/op-ai-skills/skills/phases/generation/variables.md +++ b/crates/op-ai-skills/skills/phases/generation/variables.md @@ -5,48 +5,41 @@ phase: [generation] trigger: flags: [hasVariables] priority: 45 -budget: 500 +budget: 550 category: base --- DESIGN VARIABLES: -- When document has variables, use "$variableName" references instead of hardcoded values. +- With document variables, use "$variableName" refs, not hardcoded values. - Color: [{ "type": "solid", "color": "$primary" }]. Number: "gap": "$spacing-md". -- Only reference listed variables — do NOT invent names. +- Only reference listed variables — never invent names. -## Semantic palette (14 tokens, when hasSemanticPalette=true) +## Semantic palette (14 tokens, hasSemanticPalette=true) -A document that has been seeded with `applySemanticPalette(doc)` -carries these 14 tokens with paired Light + Dark values under the -`Mode` theme axis. PREFER these refs over hex literals when the -user's intent is theme-aware (dark-mode design, system-follow app, -user-toggleable theme), because the rendered color tracks -`themes.Mode` at paint time. +A document seeded with `applySemanticPalette(doc)` carries these 14 tokens, +paired Light + Dark under the `Mode` axis. PREFER these over hex literals +when the intent is theme-aware (dark mode, system-follow, toggleable) — +the color tracks `themes.Mode` at paint time. -| Token | Light | Dark | Use for | -| ---------------------- | ----------- | ----------- | ------------------------------------ | -| `$color-surface` | `#FFFFFF` | `#1E293B` | Primary card / modal / tooltip bg | -| `$color-surface-2` | `#F1F5F9` | `#334155` | Secondary bg (chip / input / hover) | -| `$color-surface-3` | `#F3F4F6` | `#475569` | Tertiary bg (pressed / nested) | -| `$color-bg-deep` | `#F8FAFC` | `#0F172A` | Page background, skeleton hosts | -| `$color-border` | `#E2E8F0` | `#334155` | Dividers, input strokes, card border | -| `$color-border-strong` | `#CBD5E1` | `#475569` | Dashed chart-placeholder border | -| `$color-text-primary` | `#0F172A` | `#F1F5F9` | Headlines, active page numbers | -| `$color-text-body` | `#334155` | `#CBD5E1` | Body paragraphs, nav labels | -| `$color-text-muted` | `#64748B` | `#94A3B8` | Secondary text, timestamps | -| `$color-text-subtle` | `#94A3B8` | `#64748B` | Tertiary text, disabled states | -| `$color-accent` | `#2563EB` | `#60A5FA` | Primary brand, active pill, focus | -| `$color-destructive` | `#EF4444` | `#F87171` | Delete actions, error, trending-down | -| `$color-success` | `#10B981` | `#34D399` | Trending-up, online status | -| `$color-scrim` | `#00000080` | `#00000099` | Modal backdrop | +| Token | Light | Dark | Use for | +|---|---|---|---| +| `$color-surface` | `#FFFFFF` | `#1E293B` | Primary card / modal / tooltip bg | +| `$color-surface-2` | `#F1F5F9` | `#334155` | Secondary bg (chip / input / hover) | +| `$color-surface-3` | `#F3F4F6` | `#475569` | Tertiary bg (pressed / nested) | +| `$color-bg-deep` | `#F8FAFC` | `#0F172A` | Page background, skeleton hosts | +| `$color-border` | `#E2E8F0` | `#334155` | Dividers, input strokes, card border | +| `$color-border-strong` | `#CBD5E1` | `#475569` | Dashed chart-placeholder border | +| `$color-text-primary` | `#0F172A` | `#F1F5F9` | Headlines, active page numbers | +| `$color-text-body` | `#334155` | `#CBD5E1` | Body paragraphs, nav labels | +| `$color-text-muted` | `#64748B` | `#94A3B8` | Secondary text, timestamps | +| `$color-text-subtle` | `#94A3B8` | `#64748B` | Tertiary text, disabled states | +| `$color-accent` | `#2563EB` | `#60A5FA` | Primary brand, active pill, focus | +| `$color-destructive` | `#EF4444` | `#F87171` | Delete actions, error, trending-down | +| `$color-success` | `#10B981` | `#34D399` | Trending-up, online status | +| `$color-scrim` | `#00000080` | `#00000099` | Modal backdrop | -When document does NOT have the semantic palette (default -`createEmptyDocument()` state), fall back to hex literals. Do NOT -emit `$color-*` refs blindly — they'd resolve to undefined and -render as raw string text. - -Semantic colors override theme: `$color-success` stays green in -both light and dark modes (semantic meaning > visual harmony). Do -not swap `$color-success` for `$color-accent` "because the app is -dark-themed" — those carry different meanings. +Without the semantic palette (default state), fall back to hex literals — +don't emit `$color-*` refs blindly, they'd render as raw string text. +`$color-success` stays green in both modes (meaning > harmony) — don't +swap it for `$color-accent` in a dark theme, they mean different things. diff --git a/crates/op-ai-skills/src/budget.rs b/crates/op-ai-skills/src/budget.rs index 42fc5593b..4214f681e 100644 --- a/crates/op-ai-skills/src/budget.rs +++ b/crates/op-ai-skills/src/budget.rs @@ -39,6 +39,29 @@ fn warn_truncated(name: &str, budget_tokens: u32, actual_tokens: u32, dropped_ch #[cfg(target_arch = "wasm32")] fn warn_truncated(_name: &str, _budget_tokens: u32, _actual_tokens: u32, _dropped_chars: usize) {} +/// Emit a diagnostic when a skill that fits comfortably within its OWN +/// per-skill `budget` still gets its tail cut by the Step 3 domain-fill +/// knapsack because the phase's TOTAL budget ran out. This is a distinct +/// failure mode from [`warn_truncated`] above: the skill's author did +/// nothing wrong (its content is within its declared budget), but the +/// phase-level ceiling plus the skills that filled ahead of it left no +/// room for the rest. Before this, the only Step 3 signal was the +/// `truncated: true` flag on the returned [`ResolvedSkill`] — invisible +/// unless a caller inspects the report, so a Domain skill could be +/// silently chopped to a fragment of its content on every matching +/// generation call with nothing printed anywhere. +#[cfg(not(target_arch = "wasm32"))] +fn warn_tail_truncated(name: &str, own_budget: u32, actual_tokens: u32, kept_tokens: u32) { + eprintln!( + "op-ai-skills: skill {name:?} fits its own budget ({actual_tokens} tokens <= {own_budget} budget) \ + but was cut to {kept_tokens} tokens by the phase's total budget — raise the phase budget, \ + lower a higher-priority skill's footprint, or re-prioritize this skill." + ); +} + +#[cfg(target_arch = "wasm32")] +fn warn_tail_truncated(_name: &str, _own_budget: u32, _actual_tokens: u32, _kept_tokens: u32) {} + /// Truncate `content` to roughly `max_tokens`, preferring to cut at a /// newline when one falls in the second half of the window (so the /// truncation lands on a clean line break). @@ -189,6 +212,12 @@ pub fn trim_by_budget_pinned( // but only if it is at least as relevant as what already fit fully. let content = truncate_content(&skill.content, remaining as u32); let token_count = estimate_tokens(&content); + warn_tail_truncated( + &skill.meta.name, + skill.meta.budget, + skill.token_count, + token_count, + ); used += token_count as i64; result.push(ResolvedSkill { meta: skill.meta.clone(), @@ -234,6 +263,40 @@ mod tests { use super::*; use crate::types::{Phase, SkillMeta, SkillTrigger}; + /// Regression guard for the "skill content drifted over its declared + /// budget and got silently truncated" bug class (the 2026-07-24 budget + /// audit found `layout.md` had sat ~700 tokens over budget for months, + /// and caught five more in the same state: `overflow.md`, + /// `dashboard.md`, `variables.md`, `interactivity.md`, + /// `shader-fill.md` — each silently dropping content from the middle + /// or tail of a worked example, a correctness checklist, or a color + /// table). Every skill's actual token count must fit within its own + /// frontmatter `budget:`, so Step 1 of `trim_by_budget_pinned` never + /// needs to invoke [`truncate_content`] on it. Add content to a skill + /// → this test fails the moment it drifts over budget, instead of + /// failing silently in production prompts. + #[test] + fn no_skill_silently_exceeds_its_own_budget() { + let mut offenders = Vec::new(); + for skill in crate::loader::get_skill_registry() { + let actual = estimate_tokens(&skill.content); + if actual > skill.meta.budget { + offenders.push(format!( + "{} (budget={}, actual={}, over by {})", + skill.meta.name, + skill.meta.budget, + actual, + actual - skill.meta.budget + )); + } + } + assert!( + offenders.is_empty(), + "skills exceed their own per-skill budget and would be silently \ + truncated by trim_by_budget's Step 1 cap: {offenders:?}" + ); + } + fn skill(name: &str, category: SkillCategory, budget: u32, content: &str) -> SkillEntry { SkillEntry { meta: SkillMeta { diff --git a/crates/op-ai-skills/src/resolve.rs b/crates/op-ai-skills/src/resolve.rs index 5a8cfac60..9a2cb883e 100644 --- a/crates/op-ai-skills/src/resolve.rs +++ b/crates/op-ai-skills/src/resolve.rs @@ -182,7 +182,7 @@ mod tests { "design a login form", &ResolveOptions::default(), ); - assert_eq!(ctx.budget_max, 8000); + assert_eq!(ctx.budget_max, 12000); assert!(ctx.budget_used <= ctx.budget_max); assert!( !ctx.skills.is_empty(), @@ -190,6 +190,40 @@ mod tests { ); } + /// Regression guard for the 2026-07-24 budget audit: real-world + /// generation prompts (mobile app / SaaS landing page / admin + /// dashboard) used to silently truncate at least one Domain or + /// Knowledge skill via the Step 3 total-budget knapsack — even when + /// every skill fit its own per-skill budget — because the always-on + /// Base skills alone (~6000 tokens) left only ~2000 tokens of + /// headroom under the old 8000-token phase default (landing-page.md + /// was cut to 275 of its 1203 tokens; design-principles was dropped + /// from all three prompts entirely). The phase default is now 12000 + /// (see `Phase::default_budget`'s doc comment); this locks in that no + /// resolved skill in these representative prompts gets truncated by + /// either mechanism (own-budget cap or total-budget tail cut). + #[test] + fn representative_generation_prompts_hit_zero_truncation() { + for prompt in [ + "design a polished mobile app home screen for a fitness tracking app with bottom navigation", + "design a modern landing page for a SaaS product with hero, pricing, and testimonials", + "design an admin dashboard with a data table, charts, and analytics for a SaaS product", + ] { + let ctx = resolve_skills(Phase::Generation, prompt, &ResolveOptions::default()); + let truncated: Vec<&str> = ctx + .skills + .iter() + .filter(|s| s.truncated) + .map(|s| s.meta.name.as_str()) + .collect(); + assert!( + truncated.is_empty(), + "prompt {prompt:?} truncated skills: {truncated:?}" + ); + assert!(ctx.budget_used <= ctx.budget_max); + } + } + #[test] fn generation_resolve_leaves_no_unresolved_placeholder() { // With history present, the `{{recentHistory}}` placeholder in diff --git a/crates/op-ai-skills/src/types.rs b/crates/op-ai-skills/src/types.rs index cb3348800..971ef6e36 100644 --- a/crates/op-ai-skills/src/types.rs +++ b/crates/op-ai-skills/src/types.rs @@ -38,11 +38,29 @@ impl Phase { } } - /// Default token budget for this phase (TS `DEFAULT_BUDGETS`). + /// Default token budget for this phase. Planning/Validation/Maintenance + /// are the original TS `DEFAULT_BUDGETS` values (faithful port). + /// Generation was raised 8000 → 12000 (2026-07-24): the always-on Base + /// skills alone already use ~6000 tokens, so 8000 left only ~2000 for + /// Domain + Knowledge combined — routinely not enough for even one + /// well-matched Domain skill (`mobile-app` ~1950 tok, `dashboard` ~2250 + /// tok), let alone a second Domain skill plus any Knowledge skill on + /// top. `op-orchestrator/src/prompt.rs` had already reached the same + /// number independently for its Full-tier reporting default (comment: + /// "image-rich data-list sections … overflowed 8000 tokens and + /// truncated their scripts to zero generated nodes") but never actually + /// wired it into `budget_override` for that tier, so real trimming + /// silently ran at 8000 while the diagnostics reported 12000. Raising + /// the shared default here makes every caller — orchestrator Full tier, + /// the builtin-agent chat preamble (`chat_runtime.rs`), and the direct + /// chat system prompt (`chat_system_prompt.rs`) — actually get the + /// headroom the codebase had already decided was correct. Tier-scaled + /// callers (Basic/Standard mobile/desktop) are unaffected: they pass an + /// explicit `budget_override` and never fall through to this default. pub fn default_budget(self) -> u32 { match self { Phase::Planning => 4000, - Phase::Generation => 8000, + Phase::Generation => 12000, Phase::Validation => 3000, Phase::Maintenance => 5000, } @@ -52,7 +70,7 @@ impl Phase { /// Per-phase default token budgets — the TS `DEFAULT_BUDGETS` record. pub const DEFAULT_BUDGETS: [(Phase, u32); 4] = [ (Phase::Planning, 4000), - (Phase::Generation, 8000), + (Phase::Generation, 12000), (Phase::Validation, 3000), (Phase::Maintenance, 5000), ]; @@ -305,7 +323,7 @@ mod tests { #[test] fn default_budget_table() { assert_eq!(Phase::Planning.default_budget(), 4000); - assert_eq!(Phase::Generation.default_budget(), 8000); + assert_eq!(Phase::Generation.default_budget(), 12000); assert_eq!(Phase::Validation.default_budget(), 3000); assert_eq!(Phase::Maintenance.default_budget(), 5000); // The const table agrees with the per-variant method. diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 926c0fb89..1de11720d 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -1163,10 +1163,17 @@ CRITICAL LAYOUT CONSTRAINTS:\n\ // Assemble the per-subtask skill-load report from the FINAL skill set // (post tier/dedup filtering). `budget_max` reflects the tier budget - // override. Full-tier defaults to 12000 because image-rich data-list - // sections (restaurants/products with ratings/prices) overflowed 8000 - // tokens and truncated their scripts to zero generated nodes. - let budget_max = budget_override.unwrap_or(12000); + // override. Full-tier falls through to `Phase::Generation::default_budget()` + // (12000, raised from 8000 in op-ai-skills — see that constant's doc + // comment) because image-rich data-list sections (restaurants/products + // with ratings/prices) overflowed 8000 tokens and truncated their + // scripts to zero generated nodes. This used to be a bare `12000` + // literal that only affected this diagnostic number — `resolve_skills` + // (called above via `resolve_generation_skills`) independently fell + // back to the OLD 8000 default for a `None` override, so Full tier's + // real skill trimming silently ran at 8000 while this report claimed + // 12000. Deriving both from the same constant keeps them honest. + let budget_max = budget_override.unwrap_or_else(|| Phase::Generation.default_budget()); let included: Vec = filtered .iter() .map(|s| SkillLoadEntry {