fix(ai): stop silent skill prompt truncation and honor full-tier budget

Five skills had drifted past their own injection budgets (variables lost its color table tail, overflow its fallback example); the phase knapsack also tail-cut compliant domain skills with zero diagnostics, and full tier trimmed at 8000 while reporting 12000. Compress or relocate oversized content, raise the generation default to the intended 12000, warn on both truncation paths, and add regression tests pinning zero truncation.
This commit is contained in:
Fini 2026-07-24 23:34:10 +08:00
parent 1ed7930d57
commit 92ca558f39
10 changed files with 307 additions and 198 deletions

View file

@ -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.

View file

@ -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).

View file

@ -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: "<SkSL source>", 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.

View file

@ -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` | `{ "<path>": "<expr>" }` map of assignments | Write one or more state variables |
| `toggle` | `"<path>"` — the bool variable to flip | Toggle a bool state variable |
| `toast` | `"<message expr>"` string or template literal | Show a transient notification |
| `push` | `"\"<route path>\""` — a JSON string whose VALUE is itself `"<route path>"`, quotes included | Drill into a screen (keeps the caller reachable via back/pop) |
| `replace` | `"\"<route path>\""` — 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": "<condition>", "then": [...], "else": [...] }` | Conditional action branch (`else` optional) |
| Action | Body | Effect |
|---|---|---|
| `set` | `{ "<path>": "<expr>" }` map of assignments | Write one or more state variables |
| `toggle` | `"<path>"` — the bool variable to flip | Toggle a bool state variable |
| `toast` | `"<message expr>"` string or template literal | Show a transient notification |
| `push` | `"\"<route path>\""` — a JSON string whose VALUE is itself `"<route path>"`, quotes included | Drill into a screen (back/pop can return) |
| `replace` | `"\"<route path>\""` — 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": "<condition>", "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:

View file

@ -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.

View file

@ -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.

View file

@ -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 {

View file

@ -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

View file

@ -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.

View file

@ -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<SkillLoadEntry> = filtered
.iter()
.map(|s| SkillLoadEntry {