mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
Style SVG output from fabro graph and API (#18)
This PR styles the SVG output from `fabro graph` and the API graph
endpoints to match the polished look of the documentation SVGs. It
introduces two internal functions in `graph.rs`:
`inject_dot_style_defaults`, which inserts DOT-level defaults
(transparent background, teal `#357f9e` node strokes, gray `#666666`
edges, Helvetica font) after the first `{` in any DOT source, and
`postprocess_svg`, which removes the Graphviz-generated white background
polygon and injects a `<style>` block with `prefers-color-scheme: dark`
media queries for automatic dark mode support.
The `render_dot` function is updated to apply both transformations (DOT
defaults before rendering, SVG post-processing after), and is made `pub`
so the API layer can reuse it. Both `get_graph` in `server.rs` and
`get_run_graph` in `demo/mod.rs` are simplified from ~30 lines of inline
Graphviz process management down to a single call to a shared
`render_dot_svg` helper that delegates to `render_dot` on a blocking
thread.
Six new unit tests validate the styling pipeline: default injection with
and without braces, white background removal, dark mode style insertion,
and the existing SVG integration test is extended to assert styled
output. PNG output is unaffected by the SVG post-processing step.
### Fabro Details
<details>
<summary>Ran 10 stages in 23m 31s for $5.48</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $1.15 | 0 |
| simplify_opus | 0s | $1.55 | 0 |
| simplify_gemini | 0s | $1.65 | 0 |
| simplify_gpt | 0s | $1.13 | 0 |
| verify | 0s | – | 0 |
| fmt | 0s | – | 0 |
| **Total** | **23m 31s** | **$5.48** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (13 nodes and 16
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD."]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gemini [label="Simplify (Gemini)", prompt="@prompts/simplify.md", model="gemini-3.1-pro-preview-customtools"]
simplify_gpt [label="Simplify (GPT-54)", prompt="@prompts/simplify.md", model="gpt-54"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -q --workspace -- -D warnings 2>&1 && cargo nextest run --cargo-quiet --workspace --status-level fail 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
fmt [label="Format", shape=parallelogram, script="cargo fmt --all 2>&1", goal_gate=true, max_retries=0]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gemini -> simplify_gpt -> verify
verify -> fmt [condition="outcome=success"]
verify -> fixup
fixup -> verify
fmt -> exit
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
971a6f2451
commit
f7fb72a472
3 changed files with 142 additions and 72 deletions
|
|
@ -226,37 +226,7 @@ pub async fn get_run_graph(
|
|||
// Use graphviz to render the demo DOT source
|
||||
let dot_source = "digraph demo {\n graph [goal=\"Demo\"]\n rankdir=LR\n start [shape=Mdiamond, label=\"Start\"]\n detect [label=\"Detect\\nDrift\"]\n exit [shape=Msquare, label=\"Exit\"]\n propose [label=\"Propose\\nChanges\"]\n review [label=\"Review\\nChanges\"]\n apply [label=\"Apply\\nChanges\"]\n start -> detect\n detect -> exit [label=\"No drift\"]\n detect -> propose [label=\"Drift found\"]\n propose -> review\n review -> propose [label=\"Revise\"]\n review -> apply [label=\"Accept\"]\n apply -> exit\n}";
|
||||
|
||||
let mut child = match tokio::process::Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
return ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Graphviz dot command not available.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(dot_source.as_bytes()).await;
|
||||
}
|
||||
|
||||
match child.wait_with_output().await {
|
||||
Ok(output) if output.status.success() => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "image/svg+xml")],
|
||||
output.stdout,
|
||||
)
|
||||
.into_response(),
|
||||
_ => ApiError::new(StatusCode::BAD_GATEWAY, "Dot rendering failed.").into_response(),
|
||||
}
|
||||
crate::server::render_dot_svg(dot_source).await
|
||||
}
|
||||
|
||||
pub async fn get_run_retro(
|
||||
|
|
|
|||
|
|
@ -1434,6 +1434,20 @@ async fn get_retro(
|
|||
}
|
||||
}
|
||||
|
||||
/// Render DOT source to a styled SVG via `render_dot` on a blocking thread.
|
||||
pub(crate) async fn render_dot_svg(dot_source: &str) -> Response {
|
||||
use fabro_workflows::cli::graph::{render_dot, GraphFormat};
|
||||
|
||||
let source = dot_source.to_owned();
|
||||
match tokio::task::spawn_blocking(move || render_dot(&source, GraphFormat::Svg)).await {
|
||||
Ok(Ok(bytes)) => {
|
||||
(StatusCode::OK, [("content-type", "image/svg+xml")], bytes).into_response()
|
||||
}
|
||||
Ok(Err(e)) => ApiError::new(StatusCode::BAD_GATEWAY, e.to_string()).into_response(),
|
||||
Err(e) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_graph(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<Arc<AppState>>,
|
||||
|
|
@ -1447,43 +1461,7 @@ async fn get_graph(
|
|||
}
|
||||
};
|
||||
|
||||
let mut child = match tokio::process::Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
return ApiError::new(
|
||||
StatusCode::BAD_GATEWAY,
|
||||
"Graphviz dot command not available.",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let _ = stdin.write_all(dot_source.as_bytes()).await;
|
||||
// stdin is dropped here, closing the pipe
|
||||
}
|
||||
|
||||
match child.wait_with_output().await {
|
||||
Ok(output) if output.status.success() => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "image/svg+xml")],
|
||||
output.stdout,
|
||||
)
|
||||
.into_response(),
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
ApiError::new(StatusCode::BAD_GATEWAY, format!("dot failed: {stderr}")).into_response()
|
||||
}
|
||||
Err(e) => ApiError::new(StatusCode::BAD_GATEWAY, format!("dot process error: {e}"))
|
||||
.into_response(),
|
||||
}
|
||||
render_dot_svg(&dot_source).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -101,6 +101,25 @@ pub fn graph_command(args: &GraphArgs, styles: &Styles) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Dark mode CSS injected into SVG output (leading newline included for insertion).
|
||||
const DARK_MODE_STYLE: &str = r##"
|
||||
<style>
|
||||
@media (prefers-color-scheme: dark) {
|
||||
text { fill: #e0e0e0 !important; }
|
||||
[stroke="#357f9e"] { stroke: #5bb8d8; }
|
||||
[stroke="#666666"] { stroke: #999999; }
|
||||
polygon[fill="#357f9e"] { fill: #5bb8d8; }
|
||||
polygon[fill="#666666"] { fill: #999999; }
|
||||
}
|
||||
</style>"##;
|
||||
|
||||
/// DOT graph-level defaults injected after the first `{`.
|
||||
const DOT_STYLE_DEFAULTS: &str = r##"
|
||||
bgcolor="transparent"
|
||||
node [color="#357f9e", fontname="Helvetica", fontsize=12, fontcolor="#1a1a1a"]
|
||||
edge [color="#666666", fontname="Helvetica", fontsize=10, fontcolor="#666666"]
|
||||
"##;
|
||||
|
||||
static RANKDIR_RE: LazyLock<regex::Regex> =
|
||||
LazyLock::new(|| regex::Regex::new(r"rankdir\s*=\s*\w+").unwrap());
|
||||
|
||||
|
|
@ -115,8 +134,52 @@ fn apply_direction<'a>(source: &'a str, direction: Option<GraphDirection>) -> Co
|
|||
}
|
||||
}
|
||||
|
||||
/// Spawn the `dot` command to render DOT source into the given format.
|
||||
fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
||||
/// Inject DOT graph-level style defaults (transparent background, teal nodes,
|
||||
/// gray edges, Helvetica font) right after the first `{` in the DOT source.
|
||||
/// Per-node/edge attributes override these defaults.
|
||||
fn inject_dot_style_defaults(source: &str) -> String {
|
||||
let Some(pos) = source.find('{') else {
|
||||
return source.to_string();
|
||||
};
|
||||
let (before, after) = source.split_at(pos + 1);
|
||||
format!("{before}{DOT_STYLE_DEFAULTS}{after}")
|
||||
}
|
||||
|
||||
/// Post-process raw SVG output from Graphviz:
|
||||
/// 1. Remove the white background `<polygon>` element
|
||||
/// 2. Insert a dark-mode `<style>` block after the opening `<svg ...>` tag
|
||||
fn postprocess_svg(raw: Vec<u8>) -> Vec<u8> {
|
||||
let mut svg = String::from_utf8(raw)
|
||||
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
|
||||
|
||||
// Remove white background polygon (single line containing it)
|
||||
svg = svg
|
||||
.lines()
|
||||
.filter(|line| {
|
||||
!(line.contains("<polygon")
|
||||
&& line.contains("fill=\"white\"")
|
||||
&& line.contains("stroke=\"none\""))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
// Insert dark mode style block after the opening <svg ...> tag
|
||||
if let Some(svg_close) = svg
|
||||
.find("<svg")
|
||||
.and_then(|start| svg[start..].find('>').map(|end| start + end))
|
||||
{
|
||||
svg.insert_str(svg_close + 1, DARK_MODE_STYLE);
|
||||
}
|
||||
|
||||
svg.into_bytes()
|
||||
}
|
||||
|
||||
/// Render styled DOT source into the given format via the `dot` command.
|
||||
///
|
||||
/// Injects style defaults (colors, fonts, transparent background) into the DOT
|
||||
/// source, then post-processes SVG output with dark-mode CSS and background removal.
|
||||
pub fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
||||
let styled_source = inject_dot_style_defaults(source);
|
||||
let mut child = match Command::new("dot")
|
||||
.arg(format!("-T{format}"))
|
||||
.stdin(std::process::Stdio::piped())
|
||||
|
|
@ -134,7 +197,7 @@ fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
|||
};
|
||||
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
stdin.write_all(source.as_bytes())?;
|
||||
stdin.write_all(styled_source.as_bytes())?;
|
||||
}
|
||||
|
||||
let output = child.wait_with_output()?;
|
||||
|
|
@ -144,7 +207,12 @@ fn render_dot(source: &str, format: GraphFormat) -> anyhow::Result<Vec<u8>> {
|
|||
bail!("dot failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(output.stdout)
|
||||
let raw = output.stdout;
|
||||
if matches!(format, GraphFormat::Svg) {
|
||||
Ok(postprocess_svg(raw))
|
||||
} else {
|
||||
Ok(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether the `dot` command is available on PATH.
|
||||
|
|
@ -236,6 +304,14 @@ mod tests {
|
|||
|
||||
let content = std::fs::read_to_string(&output_path).unwrap();
|
||||
assert!(content.contains("<svg"), "expected SVG content");
|
||||
assert!(
|
||||
content.contains("prefers-color-scheme: dark"),
|
||||
"expected dark mode style block"
|
||||
);
|
||||
assert!(
|
||||
!content.contains("fill=\"white\""),
|
||||
"white background should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -302,6 +378,52 @@ mod tests {
|
|||
assert!(!content.is_empty(), "output file should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_dot_style_defaults_inserts_attrs() {
|
||||
let source = "digraph G {\n a -> b\n}";
|
||||
let styled = inject_dot_style_defaults(source);
|
||||
assert!(styled.contains("bgcolor=\"transparent\""));
|
||||
assert!(styled.contains("node [color=\"#357f9e\""));
|
||||
assert!(styled.contains("fontname=\"Helvetica\""));
|
||||
assert!(styled.contains("edge [color=\"#666666\""));
|
||||
// Original content preserved
|
||||
assert!(styled.contains("a -> b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inject_dot_style_defaults_no_brace() {
|
||||
let source = "no brace here";
|
||||
let result = inject_dot_style_defaults(source);
|
||||
assert_eq!(result, source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postprocess_svg_removes_white_bg() {
|
||||
let svg = b"<svg xmlns=\"...\" width=\"100\">\n<polygon fill=\"white\" stroke=\"none\" points=\"0,0 100,0 100,100 0,100\"/>\n<g>content</g>\n</svg>";
|
||||
let result = postprocess_svg(svg.to_vec());
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
assert!(
|
||||
!result_str.contains("fill=\"white\""),
|
||||
"white background polygon should be removed"
|
||||
);
|
||||
assert!(result_str.contains("<g>content</g>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postprocess_svg_injects_dark_mode() {
|
||||
let svg = b"<svg xmlns=\"...\" width=\"100\">\n<g>content</g>\n</svg>";
|
||||
let result = postprocess_svg(svg.to_vec());
|
||||
let result_str = String::from_utf8(result).unwrap();
|
||||
assert!(
|
||||
result_str.contains("prefers-color-scheme: dark"),
|
||||
"dark mode style block should be present"
|
||||
);
|
||||
// Style block should come after <svg ...>
|
||||
let svg_tag_end = result_str.find('>').unwrap();
|
||||
let style_pos = result_str.find("<style>").unwrap();
|
||||
assert!(style_pos > svg_tag_end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_toml_path() {
|
||||
if !dot_is_available() {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue