openpencil/crates/op-orchestrator/src/loop_finalize.rs

613 lines
27 KiB
Rust

//! Track-1 Step 4 — whole-doc deterministic structural-quality backstop for
//! the agentic design loop.
//!
//! The orchestrator runs an ordered **Class-A** structural sequence PER SUBTASK
//! (`subagent.rs`) on each parsed forest before insertion, then a per-root
//! cleanup (`cleanup::finalize_design`). The agentic tool-loop
//! (`op-host-services::chat_agent_loop`) runs the model + canvas tools but
//! applies NONE of those deterministic passes — it relies on the model
//! self-correcting. These passes are the invisible-in-a-screenshot
//! structural-correctness work (semantic roles, surface-color discipline,
//! design-token binding) a vision model cannot reconstruct.
//!
//! [`apply_loop_finalize`] gives the loop the SAME backstop, run ONCE at loop
//! end over the WHOLE assembled document instead of per subtask. It reuses the
//! exact pass functions the orchestrator calls (it does NOT reimplement them),
//! in the SAME order, then routes the per-root cleanup through
//! [`crate::cleanup::finalize_design`].
//!
//! ## Included vs excluded passes
//!
//! The Class-A sequence in `subagent.rs` is:
//!
//! 0. `remove_abandoned_duplicate_roots` — whole-page top-level repair → INCLUDED
//! 1. `coalesce_subtask_section` — SUBTASK-BOUNDARY dependent → EXCLUDED
//! 2. `role_infer::resolve_forest_roles` — boundary-independent → INCLUDED
//! 3. `role_post_pass::post_pass_forest` — boundary-independent → INCLUDED
//! 4. `promote::promote_forest` — boundary-independent → INCLUDED
//! 5. `tree_heuristics::apply_tree_heuristics` — boundary-independent → INCLUDED
//! 6. `variable_binding::bind_generated_color_variables` — boundary-independent → INCLUDED
//! 7. `role_post_pass::enforce_surface_color_discipline` — boundary-independent → INCLUDED
//! 8. `normalize_section_roots_for_parent_layout` — SUBTASK-BOUNDARY dependent → EXCLUDED
//! 9. `orchestration_self_check` — REJECTION gate, not a fix → EXCLUDED
//! 10. per-root `cleanup::finalize_design` — INCLUDED (whole-doc roots).
//!
//! ### Why `coalesce_subtask_section` + `normalize_section_roots_for_parent_layout`
//! are excluded
//!
//! Both require *one-section-per-forest* context. `coalesce_subtask_section`
//! reparents weak-model sibling fragments back into the single wrapper a
//! subtask is *known* to be; `normalize_section_roots_for_parent_layout`
//! normalizes a subtask's roots for the parent frame's layout axis. At loop
//! end the document is a finished multi-section tree — each top-level child is
//! an independent, already-placed section, not the split fragments of one
//! subtask — so re-coalescing or re-normalizing would corrupt the assembled
//! layout instead of repairing it. The whole-doc cleanup passes
//! (`cleanup::finalize_design`) already cover the cross-section structural
//! repairs that are safe at this boundary. See the matching SCOPE NOTE in
//! `cleanup.rs::finalize_design`.
//!
//! `orchestration_self_check` is a *rejection* gate (it fails a subtask whose
//! parse is structurally fatal so the orchestrator can retry); it does not fix
//! anything, and there is no per-subtask retry to drive at loop end, so it is
//! not part of the finalize.
use crate::repair_summary::{CheckCategory, RepairCounter, RepairSummary};
use crate::role_defaults::{detect_theme_from_fill, Theme};
use jian_ops_schema::node::PenNode;
use op_editor_core::{first_solid_fill_hex, EditorCommand, EditorState, NodeId, PenNodeExt};
use serde_json::{json, Value};
/// Default canvas width when the document has no measurable top-level frame.
/// Mirrors the desktop/web default design width.
const DEFAULT_CANVAS_WIDTH: f64 = 1200.0;
/// Minimal [`DocSink`](crate::types::DocSink) over a borrowed `&mut EditorState`
/// so the per-root cleanup passes (which speak `DocSink` + `EditorCommand`) can
/// run against the loop's live document. Modeled on the test `VecDocSink` and
/// the production `RemoteDocSink`, but synchronous + non-recording: every
/// `apply` goes straight through `EditorState::apply`.
pub(crate) struct StateDocSink<'a> {
pub(crate) state: &'a mut EditorState,
}
impl crate::types::DocSink for StateDocSink<'_> {
fn state(&self) -> &EditorState {
self.state
}
fn apply(&mut self, cmd: EditorCommand) -> bool {
self.state.apply(cmd)
}
fn insert_subtree_returning_root_ids(
&mut self,
nodes: Vec<PenNode>,
parent_id: &NodeId,
) -> Option<Vec<String>> {
self.state
.insert_subtree_returning_root_ids(nodes, parent_id)
}
fn begin_undo_batch(&mut self) {}
fn end_undo_batch(&mut self) {}
}
/// Whether `node` is a single page/screen-root wrapper: a `frame` (or group)
/// container whose children are the design's sections. This mirrors the
/// orchestrator's scaffold page-root — the frame the subtask sections are
/// inserted UNDER. When the loop's whole-doc forest is exactly one such
/// wrapper, the Class-A section passes must run on its CHILDREN (the sections),
/// not on the wrapper itself, so the page background (the wrapper's own fill)
/// is not mistaken for a redundant section surface.
fn is_page_root_wrapper(node: &PenNode) -> bool {
matches!(node, PenNode::Frame(_) | PenNode::Group(_))
&& node.children().is_some_and(|c| !c.is_empty())
}
/// Locate the "section forest" + its page context within the active page.
///
/// - **Single page-root wrapper** (one top-level container child): the section
/// forest is that wrapper's children; `page_bg`/`width`/`theme` come from the
/// wrapper. This is the orchestrator's scaffold-page-root contract — sections
/// sit UNDER the page frame, on the page background.
/// - **Flat section forest** (0 or 2+ top-level nodes): the top-level nodes ARE
/// the sections; there is no page-bg frame, so `page_bg = None`,
/// `width = first node width`, `theme` from the first node's fill.
fn locate_section_context(forest: &[PenNode]) -> (bool, Option<String>, f64, Theme) {
if forest.len() == 1 && is_page_root_wrapper(&forest[0]) {
let root = &forest[0];
let width = root
.width_px()
.filter(|w| *w > 0.0)
.unwrap_or(DEFAULT_CANVAS_WIDTH);
let page_bg = first_solid_fill_hex(root).map(str::to_string);
let theme = detect_theme_from_fill(page_bg.as_deref());
(true, page_bg, width, theme)
} else {
let first = forest.first();
let width = first
.and_then(PenNodeExt::width_px)
.filter(|w| *w > 0.0)
.unwrap_or(DEFAULT_CANVAS_WIDTH);
// A flat-section forest has no page-bg frame: each top-level node is a
// section, so there is no "redundant page background" to match against.
// Theme falls back to Light (matching `detect_theme_from_fill(None)`).
(false, None, width, Theme::Light)
}
}
/// Perceived luminance (0.299/0.587/0.114) of an `#RRGGBB` hex. `None` on a
/// non-hex string. Local reimplementation to avoid a cross-module `pub`.
fn hex_luminance(hex: &str) -> Option<f64> {
let h = hex.strip_prefix('#').unwrap_or(hex);
if !h.is_ascii() || h.len() < 6 {
return None;
}
let r = u8::from_str_radix(&h[0..2], 16).ok()? as f64 / 255.0;
let g = u8::from_str_radix(&h[2..4], 16).ok()? as f64 / 255.0;
let b = u8::from_str_radix(&h[4..6], 16).ok()? as f64 / 255.0;
Some(0.299 * r + 0.587 * g + 0.114 * b)
}
/// First solid-fill color STRING from a node's `fill` — the raw authored value
/// (a `$ref` or `#hex`), whether the fill is a bare string or the canonical
/// `[{type:"solid",color}]` array. Resolution to hex happens at the call site.
fn first_solid_color_str(fill: &Value) -> Option<String> {
match fill {
Value::String(s) if !s.trim().is_empty() => Some(s.clone()),
Value::Array(a) => a.first().and_then(|f| {
f.get("color")
.and_then(Value::as_str)
.or_else(|| f.as_str())
.map(str::to_string)
}),
_ => None,
}
}
/// A text node has a visible fill when `fill` is a non-empty array or a
/// non-blank string. glm-5.2 in the loop omits `fill` on text entirely.
fn text_has_fill(v: &Value) -> bool {
match v.get("fill") {
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::String(s)) => !s.trim().is_empty(),
_ => false,
}
}
/// Repair THEME-POLARITY splits in the variable table. A model building a
/// dark design writes its dark values into the ACTIVE theme slot for most
/// variables — but leaves a few (surface-2/3, status-\*-bg) at stock LIGHT
/// values, so a chip resolves #F1F5F9 on a #0A0A0A page (measured: glaring
/// white pills on a dark luxury dashboard). The correct dark value usually
/// EXISTS in the variable's other theme slot; adopt it.
///
/// Polarity contract, judged against the first design root's resolved page
/// background: surface-family variables (`surface|card|panel|chip|bg`) sit on
/// the SAME side as the page; text-family (`text|foreground`) sit OPPOSITE.
/// A variable is only rewritten when its active value clearly violates the
/// contract AND another slot's value clearly satisfies it — everything
/// ambiguous is left alone.
pub(crate) fn fix_theme_variable_polarity(sink: &mut dyn crate::types::DocSink) {
let fixes: Vec<(String, Value)> = {
let state = sink.state();
let Some(bg_lum) = state
.active_children()
.first()
.and_then(first_solid_fill_hex)
.map(str::to_string)
.and_then(|c| match c.strip_prefix('$') {
Some(name) => state.resolve_color_variable_hex(name),
None => Some(c),
})
.as_deref()
.and_then(hex_luminance)
else {
return;
};
let Ok(vars) = serde_json::to_value(state.doc.variables.clone()) else {
return;
};
let Some(map) = vars.as_object() else {
return;
};
let mut fixes = Vec::new();
for (name, def) in map {
if def.get("type").and_then(Value::as_str) != Some("color") {
continue;
}
let lname = name.to_lowercase();
let surface_like = ["surface", "card", "panel", "chip", "bg", "background"]
.iter()
.any(|t| lname.contains(t));
let text_like = lname.contains("text") || lname.contains("foreground");
if surface_like == text_like {
// neither family, or ambiguously both — leave alone
continue;
}
let Some(active_hex) = state.resolve_color_variable_hex(name) else {
continue;
};
let Some(active_lum) = hex_luminance(&active_hex) else {
continue;
};
// Every hex present across the variable's theme slots.
let mut slot_hexes: Vec<String> = Vec::new();
collect_hex_strings(def.get("value").unwrap_or(&Value::Null), &mut slot_hexes);
let violated = if surface_like {
(active_lum - bg_lum).abs() > 0.55
} else {
(active_lum - bg_lum).abs() < 0.22
};
if !violated {
continue;
}
let candidate = slot_hexes
.iter()
.filter_map(|h| hex_luminance(h).map(|l| (h, l)))
.filter(|(h, _)| !h.eq_ignore_ascii_case(&active_hex))
.filter(|(_, l)| {
if surface_like {
(l - bg_lum).abs() < 0.35
} else {
(l - bg_lum).abs() > 0.5
}
})
.min_by(|a, b| {
let key = |l: f64| {
if surface_like {
(l - bg_lum).abs()
} else {
-(l - bg_lum).abs()
}
};
key(a.1).total_cmp(&key(b.1))
})
.map(|(h, _)| h.clone());
if let Some(hex) = candidate {
// Overwrite the SLOT the resolver actually hit (the entry whose
// value equals the resolved active hex) — `SetVariableColor`
// routes through the session's active-theme pin, which a
// freshly-loaded document does not carry, and would append a
// themeless entry the resolver never reads.
let mut def = def.clone();
if replace_hex_slot(
def.get_mut("value").unwrap_or(&mut Value::Null),
&active_hex,
&hex,
) {
fixes.push((name.clone(), def));
}
}
}
fixes
};
if fixes.is_empty() {
return;
}
let mut variables = std::collections::BTreeMap::new();
for (name, def) in fixes {
if let Ok(parsed) =
serde_json::from_value::<jian_ops_schema::variable::VariableDefinition>(def)
{
variables.insert(name, parsed);
}
}
if !variables.is_empty() {
sink.apply(EditorCommand::SetVariables {
variables,
replace: false,
});
}
}
/// Replace the FIRST slot in a variable-value JSON whose `value` equals
/// `from` with `to`. Returns whether a slot changed.
fn replace_hex_slot(v: &mut Value, from: &str, to: &str) -> bool {
match v {
Value::String(s) if s.eq_ignore_ascii_case(from) => {
*s = to.to_string();
true
}
Value::Array(a) => a.iter_mut().any(|item| replace_hex_slot(item, from, to)),
Value::Object(o) => o
.get_mut("value")
.map(|inner| replace_hex_slot(inner, from, to))
.unwrap_or(false),
_ => false,
}
}
/// Collect every `#hex` string in a variable-value JSON (scalar or the themed
/// `[{value, theme}]` array).
fn collect_hex_strings(v: &Value, out: &mut Vec<String>) {
match v {
Value::String(s) if s.starts_with('#') => out.push(s.clone()),
Value::Array(a) => {
for item in a {
collect_hex_strings(item, out);
}
}
Value::Object(o) => {
for item in o.values() {
collect_hex_strings(item, out);
}
}
_ => {}
}
}
/// Give every fill-less text node a fill that contrasts with its nearest
/// resolved background. A model (glm-5.2 in the agentic loop) defines a light
/// `primary_text` variable but omits `fill` on the text nodes themselves, so
/// they render with the default (dark) color — invisible on the dark surfaces
/// it also built (measured: a full 176-node dashboard rendered near-black on
/// black). `role_post_pass` only fills BUTTON text (`fix_button_foreground_
/// contrast`); general text (headings, KPI labels, table cells) had no
/// fill-injection pass. This is Pencil's hard rule "text without a fill is
/// invisible — always set one". `$ref` backgrounds resolve through the live
/// variable table (`state.resolve_color_variable_hex`).
fn ensure_text_fill_forest(nodes: &mut [PenNode], state: &EditorState) {
/// What we know about the surface a text sits on. `Indeterminate` marks a
/// PRESENT fill we cannot reduce to a solid hex (gradient / image /
/// unresolvable `$ref`) — injecting a guessed color there risks the exact
/// invisible-text bug this pass exists to fix (dark text on a dark
/// gradient hero), so those texts are left alone.
#[derive(Clone)]
enum Bg {
Known(String),
Indeterminate,
Unknown,
}
fn resolve_hex(color: &str, state: &EditorState) -> Option<String> {
match color.strip_prefix('$') {
Some(name) => state.resolve_color_variable_hex(name),
None if color.starts_with('#') => Some(color.to_string()),
None => None,
}
}
fn has_present_fill(v: &Value) -> bool {
match v.get("fill") {
Some(Value::Array(a)) => !a.is_empty(),
Some(Value::String(s)) => !s.trim().is_empty(),
_ => false,
}
}
fn walk(v: &mut Value, bg: &Bg, state: &EditorState) {
// This node's own fill becomes the background its descendants sit on;
// absent fills inherit the ancestor's; present-but-unresolvable fills
// poison the chain to Indeterminate rather than silently inheriting.
let is_text = v.get("type").and_then(Value::as_str) == Some("text");
let child_bg = if is_text || !has_present_fill(v) {
bg.clone()
} else {
match v
.get("fill")
.and_then(first_solid_color_str)
.and_then(|c| resolve_hex(&c, state))
{
Some(hex) => Bg::Known(hex),
None => Bg::Indeterminate,
}
};
if is_text && !text_has_fill(v) {
let fg = match &child_bg {
Bg::Known(hex) => {
let lum = hex_luminance(hex).unwrap_or(1.0);
Some(if lum < 0.5 { "#F5F5F5" } else { "#111827" })
}
// No surface anywhere above → the default page is light; dark
// text is the safe default.
Bg::Unknown => Some("#111827"),
Bg::Indeterminate => None,
};
if let (Some(fg), Some(obj)) = (fg, v.as_object_mut()) {
obj.insert("fill".into(), json!([{ "type": "solid", "color": fg }]));
}
}
if let Some(kids) = v.get_mut("children").and_then(Value::as_array_mut) {
for c in kids.iter_mut() {
walk(c, &child_bg, state);
}
}
}
for node in nodes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*node) else {
continue;
};
walk(&mut v, &Bg::Unknown, state);
if let Ok(nn) = serde_json::from_value::<PenNode>(v) {
*node = nn;
}
}
}
/// Run the WHOLE-DOC subset of the orchestrator's Class-A structural sequence
/// over the assembled document, in the SAME order the per-subtask path uses,
/// then route the per-root cleanup through [`crate::cleanup::finalize_design`].
///
/// This is the agentic-loop counterpart to the per-subtask passes in
/// `subagent.rs`. It is invoked ONCE at loop end (see
/// `op-host-services::chat_agent_loop`). The orchestrator path does NOT call
/// this — it already runs the same passes per subtask.
///
/// The Class-A section passes run on the **section forest** — the page-root
/// wrapper's children when the doc is a single page frame, else the top-level
/// nodes themselves (see [`locate_section_context`]). This matches the
/// orchestrator, where the scaffold page-root is created separately and the
/// section passes only ever see the subtask sections, never the page frame.
/// Feeding the page frame itself through `apply_tree_heuristics` would let its
/// (legitimate) background fill be stripped as a "redundant section surface".
///
/// See the module doc for the included-vs-excluded pass list and the rationale
/// for excluding the two subtask-boundary-dependent passes.
pub fn apply_loop_finalize(state: &mut EditorState) {
let _ = apply_loop_finalize_counted(state);
}
/// [`apply_loop_finalize`] that also reports what its quality passes checked
/// and repaired, so the loop can surface a truthful credential to the user
/// instead of leaving every repair in the logs. Behaviourally identical — the
/// returned [`RepairSummary`] is pure measurement (see
/// `crate::repair_summary`). An empty summary means the passes never ran
/// (empty document), NOT "checked and found nothing".
pub fn apply_loop_finalize_counted(state: &mut EditorState) -> RepairSummary {
let mut summary = RepairSummary::default();
if state.active_children().is_empty() {
return summary;
}
{
let mut counter = RepairCounter::new();
let mut base = StateDocSink { state: &mut *state };
let mut counting = counter.wrap(&mut base);
let sink: &mut dyn crate::types::DocSink = &mut counting;
crate::abandoned_duplicate_roots::remove_abandoned_duplicate_roots(sink);
crate::cleanup::remove_duplicate_bottom_nav_sections_for_all_roots(sink);
// Nav-surface normalization (72px row, space_between, centered items)
// previously ran only on the orchestrator path — the agentic loop's
// hand-built navs shipped crooked (GLM-5.2 2026-07-11).
crate::cleanup::repair_mobile_structural_chrome_for_all_roots(sink);
crate::avatar_repair::repair_avatar_slots_for_all_roots(sink);
crate::cleanup::anchor_bottom_nav_last_for_all_roots(sink);
counter.checkpoint(&mut summary, CheckCategory::Structure);
crate::mobile_content_rail::repair_mobile_content_rails_for_all_roots(sink);
crate::cleanup::distribute_bottom_nav_tabs_for_all_roots(sink);
crate::cleanup::collapse_nested_horizontal_padding_for_all_roots(sink);
crate::cleanup::expand_absolute_container_to_children_for_all_roots(sink);
crate::cleanup::pad_clipping_horizontal_row_for_stroke_for_all_roots(sink);
crate::cleanup::equalize_horizontal_card_heights_for_all_roots(sink);
crate::cleanup::collapse_fill_container_content_sections_for_all_roots(sink);
crate::geometry_validation::repair_mobile_bottom_breathing_for_all_roots(sink);
counter.checkpoint(&mut summary, CheckCategory::Layout);
}
if state.active_children().is_empty() {
return summary;
}
// -- Resolve the section forest + its page context. --
let (has_wrapper, page_bg, canvas_width, theme) =
locate_section_context(state.active_children());
let light_theme = theme == Theme::Light;
{
// `bind_generated_color_variables` reads the live state while the forest
// is mutably borrowed, so snapshot the state for it first.
let snapshot = state.clone();
// The section forest: the page-root wrapper's children, or the top-level
// nodes themselves. The page-root frame is intentionally NOT fed through
// these section-level passes (its background is not a section surface).
let forest: &mut [PenNode] = if has_wrapper {
state.active_children_mut()[0]
.children_mut()
.map(Vec::as_mut_slice)
.unwrap_or_default()
} else {
state.active_children_mut()
};
// Dominant brand accent over the section forest (drives the
// invisible-band fill in `apply_tree_heuristics`).
let prior_accent = crate::tree_heuristics::dominant_design_accent(forest);
crate::role_infer::resolve_forest_roles(forest, canvas_width, theme);
crate::role_post_pass::post_pass_forest(forest, canvas_width);
jian_ops_schema::promote::promote_forest(forest);
crate::tree_heuristics::apply_tree_heuristics(
forest,
page_bg.as_deref(),
light_theme,
prior_accent.as_deref(),
);
crate::variable_binding::bind_generated_color_variables(forest, &snapshot);
crate::role_post_pass::enforce_surface_color_discipline(forest);
}
// Hoist node-level `state` into the document root, mirroring the
// orchestrator's per-subtask `hoist_app_state` — without this the
// agentic-loop path leaves `$app.*` unseeded, so generated bindings
// and events reference keys that never reach `doc.state`. Runs over
// the REAL top-level nodes (not the unwrapped section forest) so a
// page wrapper's own `state` is hoisted too. Unplanned priority:
// any planned subtask default wins a conflict; doc-owned keys
// always win regardless.
let hoist_cmd = op_editor_core::hoist_app_state(
state.active_children_mut(),
op_editor_core::UNPLANNED_APP_STATE_IDX,
);
if matches!(
&hoist_cmd,
EditorCommand::MergeAppState { state: hoisted, .. } if !hoisted.is_empty()
) {
state.apply(hoist_cmd);
}
// The app-shell restructure (flat-vertical sidebar dashboard → horizontal
// [sidebar | content]) runs inside `cleanup::finalize_design` below, the
// whole-doc finalize point SHARED with the orchestrator path — so both the
// agentic loop and the orchestrator get it exactly once, after roles are
// resolved. See `cleanup::run_cleanup_passes`.
// -- Per-root cleanup (`cleanup::finalize_design`) over every top-level
// root, via a borrowed-state DocSink. The cleanup passes are whole-root
// (they take the page-root id and recurse), so they always run over the
// real top-level nodes — NOT the unwrapped section forest. A synthesized
// minimal plan carries the root frame's name (the only plan field the
// cleanup passes read — dashboard keyword matching) + its width/fill. --
let root_ids: Vec<String> = state
.active_children()
.iter()
.map(|n| n.id_str().to_string())
.collect();
let plan = synthesize_plan(state.active_children(), canvas_width);
{
let mut sink = StateDocSink { state: &mut *state };
let root_id_refs: Vec<&str> = root_ids.iter().map(String::as_str).collect();
crate::cleanup::finalize_design_with_summary(&mut sink, &plan, &root_id_refs, &mut summary);
}
// Fill-less text → a background-contrasting fill, over the FULLY
// restructured tree — AFTER the app-shell reshape has moved the sidebar into
// place — so every text node, including a restructured sidebar's nav labels,
// is reached (running it earlier over the section forest missed the sidebar
// text: measured glm-5.2 left all 10 sidebar labels fill-less → invisible).
// `role_post_pass` only fills button text; a loop model leaves general text
// fill-less. A fresh snapshot backs the immutable `$variable` resolution
// while the tree is mutated.
let snapshot = state.clone();
ensure_text_fill_forest(state.active_children_mut(), &snapshot);
summary
}
/// Build the minimal [`OrchestratorPlan`](crate::plan::OrchestratorPlan) the
/// cleanup passes need. Only `root_frame.name` (dashboard keyword matching) +
/// `width`/`fill` are read by `finalize_design`; `subtasks` is left empty.
fn synthesize_plan(forest: &[PenNode], canvas_width: f64) -> crate::plan::OrchestratorPlan {
let root = forest.first();
let name = root
.and_then(|n| n.base().name.clone())
.unwrap_or_else(|| "Page".to_string());
let fill = root.and_then(first_solid_fill_hex).map(|hex| {
vec![crate::plan::PlanFill {
kind: "solid".to_string(),
color: hex.to_string(),
}]
});
crate::plan::OrchestratorPlan {
root_frame: crate::plan::RootFrameSpec {
id: root.map(|n| n.id_str().to_string()).unwrap_or_default(),
name,
width: canvas_width,
height: 0.0,
layout: None,
gap: None,
padding: None,
fill,
},
subtasks: Vec::new(),
style_guide_name: None,
}
}
#[cfg(test)]
#[path = "loop_finalize_tests.rs"]
mod tests;