From ee25b87d16c346f32d21ddfe8da0a29c7fce6b8a Mon Sep 17 00:00:00 2001 From: Fini Date: Fri, 24 Jul 2026 00:11:06 +0800 Subject: [PATCH] fix(agent): align mobile rail guidance --- .../op-ai-skills/skills/domains/mobile-app.md | 32 ++++++++++++------- .../skills/knowledge/design-principles.md | 2 +- .../skills/phases/generation/layout.md | 14 ++++---- .../skills/phases/generation/mobile-ui.md | 4 ++- crates/op-ai-skills/src/lib.rs | 7 ++-- crates/op-ai-skills/src/loader.rs | 9 ++++++ crates/op-orchestrator/src/prompt.rs | 7 +++- crates/op-orchestrator/src/prompt_tests.rs | 26 ++++++++++++++- 8 files changed, 77 insertions(+), 24 deletions(-) diff --git a/crates/op-ai-skills/skills/domains/mobile-app.md b/crates/op-ai-skills/skills/domains/mobile-app.md index 3b739003b..403812488 100644 --- a/crates/op-ai-skills/skills/domains/mobile-app.md +++ b/crates/op-ai-skills/skills/domains/mobile-app.md @@ -11,8 +11,9 @@ category: domain MOBILE APP — MANDATORY THREE-SECTION ARCHITECTURE: -Every mobile screen is composed as a vertical stack of exactly three sections. -You MUST define all three before generating any content. +Every mobile screen accounts for three logical layers: status chrome, app content, +and optional bottom navigation. These are architectural layers, not a requirement +to wrap every content section inside one padded App Content frame. Screen-height contract: use numeric 390-393×844 as a temporary construction seed so an empty skeleton is visible. Before finishing, a normal content-driven mobile page switches its root to `height="fit_content"` (Hug), matching its completed flow. Keep a numeric viewport only when the user explicitly requested that viewport/device frame or the design deliberately contains one clipped viewport body that must consume remaining height. @@ -28,17 +29,25 @@ The status bar (time, signal, wifi, battery) is **automatically pre-inserted** b Chip rows (filter/date/guests pills): each chip HUGS (width fit_content, single-line text, height 36-44, cornerRadius=full); the ROW clips overflow (clipContent) instead of squeezing chips — never let a pill's text wrap. A badge/pill/button frame ALWAYS carries its content (text or icon) — an empty decorated frame renders as a mystery blob. -ALL content elements must sit inside ONE wrapper container (vertical stack). +Keep the mobile root at 0 horizontal padding so status chrome, integrated bottom +navigation, and intentional full-bleed media can remain full width. Emit ordinary +app content as transparent root-direct section frames with +`width="fill_container"`, `height="fit_content"`, `layout="vertical"`, and the +same `padding: [0,24]` rail exactly once per section. Do not repeat that +horizontal inset on child wrappers. Only a deliberately clipped scroll viewport +under an explicit fixed-height root may use `height="fill_container"`; in that +case it is the ONE named remainder consumer, not a sizing mode copied onto its +sections. -The ordinary content wrapper is `width="fill_container"`, `height="fit_content"`, `layout="vertical"`. Its child sections/cards also Hug Height. Only a deliberately clipped scroll viewport under an explicit fixed-height root may use `height="fill_container"`; in that case it is the ONE named remainder consumer, not a sizing mode copied onto its sections. +Use gap and vertical padding, not margins or empty spacers, for rhythm between +sections. Give the last ordinary content section enough bottom padding to clear +the following bottom navigation. -Wrapper provides: +A clipped horizontal scroller is the rail exception: its section stays full +width, its section header gets 24px left/right padding, and its clipped +viewport gets a 24px leading inset with a flush 0px trailing edge. -- Consistent left/right padding: 16-28px (applied ONCE at wrapper level; reference-measured screens use [0,24] on the wrapper with a 402px root) -- Gap-based vertical spacing between sections (use gap, NOT margins): 24-40 by density -- padding-bottom equal to the gap value for bottom space (NOT spacer elements) - -Content stacking order inside the wrapper: +Content stacking order across the ordinary sections: 1. Top context: title / navigation header / search / filters 2. Primary content: the main "job to be done" for this screen @@ -66,7 +75,8 @@ Rules: DO NOT: -- Add per-section horizontal padding (wrapper handles it) +- Put the 24px content rail on the root page or duplicate it on an inner wrapper +- Let an ordinary root-direct content section touch the screen edge - Use spacer elements for bottom space (use padding-bottom) - Cram multiple competing sections above the fold diff --git a/crates/op-ai-skills/skills/knowledge/design-principles.md b/crates/op-ai-skills/skills/knowledge/design-principles.md index 80e068338..4078b69f8 100644 --- a/crates/op-ai-skills/skills/knowledge/design-principles.md +++ b/crates/op-ai-skills/skills/knowledge/design-principles.md @@ -21,4 +21,4 @@ DESIGN CRAFT: - Cards: consistent cornerRadius/padding/shadow. Content: image - title - description - action. - Nav: logo + 3-5 links + CTA. space_between distribution. Keep minimal. - Alternate section backgrounds (white/#F8FAFC) for natural separation. -- Mobile: one App Content wrapper owns horizontal padding and vertical gaps; inner sections should not each add competing gutters. +- Mobile: keep the page root at 0 horizontal padding for full-width chrome/full-bleed media; ordinary transparent root-direct content sections each own the same 24px rail exactly once. A clipped horizontal scroller stays full width, with a 24px-inset header and a 24px leading/0px trailing viewport inset. diff --git a/crates/op-ai-skills/skills/phases/generation/layout.md b/crates/op-ai-skills/skills/phases/generation/layout.md index b76177fd6..273308c8c 100644 --- a/crates/op-ai-skills/skills/phases/generation/layout.md +++ b/crates/op-ai-skills/skills/phases/generation/layout.md @@ -138,9 +138,11 @@ AESTHETIC HYGIENE — keep these silent (never emit, the post-pass also strips t - INNER LAYOUT FRAMES (sections, wrappers, header / body containers inside a card) DO NOT need fill, stroke, OR shadow. They inherit from the page / card surface. Only opt into a fill / border / shadow on the OUTER card, button, badge, chip — NEVER on the wrapper that holds it. -- ONE PAGE GUTTER, ON THE ROOT. The root frame carries the horizontal gutter (e.g. - `padding: [0,20]`); EVERY content section uses horizontal padding 0 and only sets vertical - padding. This is what keeps every section's left edge aligned — if sections each set their own - h-padding (one 20, one 16, one 0) their content no longer lines up. Hero / banner / image-bleed - sections sit edge-to-edge by simply NOT adding horizontal padding (the root gutter shows - through). Never stack both (root gutter + per-section h-padding = a doubled inset). +- MOBILE CONTENT RAIL LIVES ON ORDINARY SECTIONS, NOT THE PAGE ROOT. A mobile root may keep + horizontal padding 0 so the status bar, integrated bottom navigation, and intentional + full-bleed media can stay full width. Every ordinary transparent root-direct content section + owns the same 24px left/right rail exactly once (`padding: [0,24]`); do not repeat that inset + on an inner wrapper. A clipped horizontal scroller is the exception: keep its section/viewport + full width, inset its header 24px on both sides, and give the clipped viewport a 24px leading + inset with a flush 0px trailing edge. Never stack root + section or section + inner-wrapper + horizontal padding. diff --git a/crates/op-ai-skills/skills/phases/generation/mobile-ui.md b/crates/op-ai-skills/skills/phases/generation/mobile-ui.md index 63619cf4e..1a9027849 100644 --- a/crates/op-ai-skills/skills/phases/generation/mobile-ui.md +++ b/crates/op-ai-skills/skills/phases/generation/mobile-ui.md @@ -17,7 +17,9 @@ NO PHONE MOCKUP WRAPPER: The whole design IS a mobile screen. Do NOT wrap your s MOBILE WIDTH SAFETY: Every visible child must stay inside the 390px screen width. Do not create horizontal rows, chips, cards, or buttons that overflow outside the root; wrap, shrink, or clip horizontal lists instead. -MOBILE SINGLE CONTENT RAIL: All non-chrome sections must align to the same 24px left/right content rail. Section roots should stay transparent with width="fill_container" and height="fit_content"; apply padding once at the section root or first content wrapper. Do not create full-width colored wrapper surfaces just to hold content. +MOBILE SINGLE CONTENT RAIL: The root page may keep 0 horizontal padding so the pre-inserted status bar, integrated bottom navigation, and intentional full-bleed media remain full width. Every ordinary transparent root-direct content section must own the same 24px left/right rail exactly once (`padding: [0,24]`) with width="fill_container" and height="fit_content". Do not repeat that inset on an inner wrapper, and do not create full-width colored wrapper surfaces just to hold content. + +MOBILE SCROLLER RAIL: A clipped horizontal scroller is the exception to section padding. Keep the scroller section full width, inset its header 24px on both sides, and give the clipped viewport a 24px leading inset with a flush 0px trailing edge so its first item aligns to the content rail while the next item can clip. {{mobileRhythm}} diff --git a/crates/op-ai-skills/src/lib.rs b/crates/op-ai-skills/src/lib.rs index 55c039feb..53b65e1fb 100644 --- a/crates/op-ai-skills/src/lib.rs +++ b/crates/op-ai-skills/src/lib.rs @@ -294,9 +294,10 @@ mod tests { "mobile-app domain skill must resolve for a mobile ask" ); assert!( - prompt.contains("ordinary content wrapper") - && prompt.contains("height=\"fit_content\""), - "mobile domain must teach Hug Height as the ordinary content default" + prompt.contains("transparent root-direct section frames") + && prompt.contains("height=\"fit_content\"") + && prompt.contains("padding: [0,24]"), + "mobile domain must teach Hug Height and the section-owned content rail" ); } diff --git a/crates/op-ai-skills/src/loader.rs b/crates/op-ai-skills/src/loader.rs index 3b519fca3..5d74d3fe7 100644 --- a/crates/op-ai-skills/src/loader.rs +++ b/crates/op-ai-skills/src/loader.rs @@ -132,6 +132,15 @@ mod tests { assert!(skill.content.contains("Mobile top rhythm")); assert!(skill.content.contains("Do not force bottom navigation")); assert!(skill.content.contains("Not a floating pill")); + assert!(skill + .content + .contains("transparent root-direct section frames")); + assert!(skill.content.contains("padding: [0,24]")); + assert!(skill.content.contains("24px leading inset")); + assert!(skill.content.contains("0px trailing")); + assert!(!skill + .content + .contains("ALL content elements must sit inside ONE wrapper")); assert!(skill .content .contains("Do not repeat the same predictable mobile stack")); diff --git a/crates/op-orchestrator/src/prompt.rs b/crates/op-orchestrator/src/prompt.rs index 760a1eb92..926c0fb89 100644 --- a/crates/op-orchestrator/src/prompt.rs +++ b/crates/op-orchestrator/src/prompt.rs @@ -1037,6 +1037,11 @@ fn build_subagent_prompt_core( .as_ref() .map(|instruction| format!("{instruction}\n\n")) .unwrap_or_default(); + let spacing_rule = if is_mobile_layout { + "SPACING CONSISTENCY — MOBILE CONTENT RAIL: The root page may keep 0 horizontal padding for full-width status/navigation/full-bleed media. This ordinary transparent root-direct section owns padding:[0,24] exactly once; do not duplicate it on an inner wrapper. If this section is a clipped horizontal scroller, keep its section full width, inset its header 24px on both sides, and give the clipped viewport a 24px leading inset with a flush 0px trailing edge." + } else { + "SPACING CONSISTENCY: Use a single outer content gutter and consistent internal gaps. Do not create nested wrappers with conflicting padding or content touching edges." + }; // Two constraints differ by output protocol. The public subagent path uses // the script-gen branch; the raw-JSONL branch is legacy-only for direct @@ -1064,7 +1069,7 @@ CRITICAL LAYOUT CONSTRAINTS:\n\ - Target content amount: ~{:.0}px tall. Generate enough elements to fill this area.\n\ - DENSITY: Do NOT pack the area edge-to-edge. Prefer fewer, stronger modules with visible negative space; most sections should have 3-5 primary rows/cards at most.\n\ - VISUAL HIERARCHY: Each section must have one clear focal element, secondary supporting text, and quieter metadata. Avoid equal-weight blocks competing for attention.\n\ -- SPACING CONSISTENCY: Use a single outer content gutter and consistent internal gaps. Do not create nested wrappers with conflicting padding or content touching edges.\n\ +- {spacing_rule}\n\ - CRAFT POLISH: Add refinement through restrained 1px low-contrast borders, tonal surfaces, small state badges, and subtle shadows. Avoid template-like thick outlines, giant pills, or flat blocks with no micro-detail.\n\ - MEDIA CONSISTENCY: Use photographic images sparingly and keep them visually consistent in subject, crop, tone, and radius. For food/category UI, prefer cohesive icon or illustration tiles over random unrelated photos.\n\ - ICON SCALE: Icons support content; keep most icons 16-22px inside 36-48px controls. Avoid oversized circular icon bubbles or repeated identical icon treatments unless the design brief calls for them.\n\ diff --git a/crates/op-orchestrator/src/prompt_tests.rs b/crates/op-orchestrator/src/prompt_tests.rs index d6fa9b3ac..1c4ac64ed 100644 --- a/crates/op-orchestrator/src/prompt_tests.rs +++ b/crates/op-orchestrator/src/prompt_tests.rs @@ -219,7 +219,7 @@ fn subagent_prompt_carries_ts_layout_contract() { false, ); - let required = "Page sections:|Food Categories [category chips]|\"fill_container\"|\"fit_content\"|Generate enough elements|MOBILE STATUS BAR|time, signal, wifi, battery|NO PHONE MOCKUP WRAPPER|MOBILE WIDTH SAFETY|MOBILE SINGLE CONTENT RAIL|MOBILE SEARCH BAR|MOBILE SECTION CHROME|MOBILE VERTICAL RHYTHM|MOBILE TOP RHYTHM|MOBILE GRID ALIGNMENT|MOBILE CARD OVERLAYS|MOBILE IMAGE PRESENTATION|verify only rendering integrity|Do not judge or replace a displayed image during self-check based on subject relevance|explicit user-requested image edit remains allowed|NO BLANK PLACEHOLDERS|MOBILE NAV SURFACE|MOBILE NAV SHADOW|NO FIXED FOOD TEMPLATE|Do not default to the same search + categories + orange promo + two product cards composition|TYPOGRAPHY HIERARCHY|DENSITY|VISUAL HIERARCHY|SPACING CONSISTENCY|CRAFT POLISH|MEDIA CONSISTENCY|ICON SCALE|SIGNATURE MOMENT|WOW FACTOR|COMPOSITIONAL CONTRAST|PREMIUM DETAIL|NO DECORATION SPAM"; + let required = "Page sections:|Food Categories [category chips]|\"fill_container\"|\"fit_content\"|Generate enough elements|MOBILE STATUS BAR|time, signal, wifi, battery|NO PHONE MOCKUP WRAPPER|MOBILE WIDTH SAFETY|MOBILE SINGLE CONTENT RAIL|MOBILE SCROLLER RAIL|MOBILE SEARCH BAR|MOBILE SECTION CHROME|MOBILE VERTICAL RHYTHM|MOBILE TOP RHYTHM|MOBILE GRID ALIGNMENT|MOBILE CARD OVERLAYS|MOBILE IMAGE PRESENTATION|verify only rendering integrity|Do not judge or replace a displayed image during self-check based on subject relevance|explicit user-requested image edit remains allowed|NO BLANK PLACEHOLDERS|MOBILE NAV SURFACE|MOBILE NAV SHADOW|NO FIXED FOOD TEMPLATE|Do not default to the same search + categories + orange promo + two product cards composition|TYPOGRAPHY HIERARCHY|DENSITY|VISUAL HIERARCHY|SPACING CONSISTENCY|CRAFT POLISH|MEDIA CONSISTENCY|ICON SCALE|SIGNATURE MOMENT|WOW FACTOR|COMPOSITIONAL CONTRAST|PREMIUM DETAIL|NO DECORATION SPAM"; // Mobile UI guardrails now load via the `mobile-ui` skill (system prompt); // section + quality markers stay in the user prompt. Accept either. The // `"fill_container"` / `"fit_content"` markers are quote-only (no @@ -234,6 +234,30 @@ fn subagent_prompt_carries_ts_layout_contract() { "missing {required}" ); } + let combined = format!("{}\n{}", cr.system_prompt, cr.user_prompt); + for required in [ + "root page may keep 0 horizontal padding", + "ordinary transparent root-direct content section", + "padding:[0,24]", + "inset its header 24px on both sides", + "24px leading inset", + "0px trailing edge", + ] { + assert!( + combined.contains(required), + "missing mobile content-rail rule {required}" + ); + } + for stale in [ + "ONE PAGE GUTTER, ON THE ROOT", + "ALL content elements must sit inside ONE wrapper", + "Add per-section horizontal padding (wrapper handles it)", + ] { + assert!( + !combined.contains(stale), + "stale mobile gutter rule must not survive: {stale}" + ); + } assert!(!cr.system_prompt.contains("MOBILE IMAGE QUALITY")); assert!(!cr.user_prompt.contains("MOBILE IMAGE QUALITY")); }