diff --git a/etch/src/html.rs b/etch/src/html.rs
new file mode 100644
index 0000000..b7d2104
--- /dev/null
+++ b/etch/src/html.rs
@@ -0,0 +1,167 @@
+//! Interactive HTML wrapper for etch SVG output.
+//!
+//! Produces a self-contained HTML document with embedded SVG and JavaScript
+//! for pan, zoom, selection, and group highlighting. No external dependencies.
+
+use crate::layout::GraphLayout;
+use crate::svg::{SvgOptions, render_svg};
+
+/// Options for HTML output.
+#[derive(Debug, Clone)]
+pub struct HtmlOptions {
+ /// Page title.
+ pub title: String,
+ /// Show minimap (Phase 3b — reserved).
+ pub minimap: bool,
+ /// Enable search (Phase 3b — reserved).
+ pub search: bool,
+ /// Show legend (Phase 3b — reserved).
+ pub legend: bool,
+ /// Enable semantic zoom (CSS classes at low zoom levels).
+ pub semantic_zoom: bool,
+}
+
+impl Default for HtmlOptions {
+ fn default() -> Self {
+ Self {
+ title: "Graph".into(),
+ minimap: true,
+ search: true,
+ legend: true,
+ semantic_zoom: true,
+ }
+ }
+}
+
+/// Render a [`GraphLayout`] as a self-contained interactive HTML document.
+///
+/// The returned string is a complete HTML page with embedded SVG and
+/// JavaScript for pan, zoom, selection, and group highlighting.
+pub fn render_html(
+ layout: &GraphLayout,
+ svg_options: &SvgOptions,
+ html_options: &HtmlOptions,
+) -> String {
+ let svg_content = render_svg(layout, svg_options);
+ let js = include_str!("html_interactivity.js");
+ let title = &html_options.title;
+
+ format!(
+ r#"
+
+
+
+
+{title}
+
+
+
+
+{svg_content}
+
+
+
+"#
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::layout::{EdgeInfo, LayoutOptions, NodeInfo, layout};
+ use petgraph::Graph;
+ use petgraph::graph::{EdgeIndex, NodeIndex};
+
+ fn build_test_layout() -> GraphLayout {
+ let mut g = Graph::new();
+ let a = g.add_node("A");
+ let b = g.add_node("B");
+ g.add_edge(a, b, "link");
+
+ layout(
+ &g,
+ &|_idx: NodeIndex, n: &&str| NodeInfo {
+ id: n.to_string(),
+ label: n.to_string(),
+ node_type: "default".into(),
+ sublabel: None,
+ parent: None,
+ ports: vec![],
+ },
+ &|_idx: EdgeIndex, e: &&str| EdgeInfo {
+ label: e.to_string(),
+ source_port: None,
+ target_port: None,
+ },
+ &LayoutOptions::default(),
+ )
+ }
+
+ #[test]
+ fn html_contains_svg_and_script() {
+ let gl = build_test_layout();
+ let html = render_html(&gl, &SvgOptions::default(), &HtmlOptions::default());
+ assert!(html.contains(""));
+ assert!(html.contains("