diff --git a/crates/op-html/Cargo.toml b/crates/op-html/Cargo.toml
new file mode 100644
index 000000000..d92df5905
--- /dev/null
+++ b/crates/op-html/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "op-html"
+version.workspace = true
+edition = "2021"
+description = "HTML/CSS-subset importer: converts HTML into editable PenNode trees"
+license = "MIT"
+
+[dependencies]
+html5ever = "0.27"
+markup5ever_rcdom = "0.3"
+base64 = "0.22"
+jian-ops-schema = { path = "../../vendor/jian/crates/jian-ops-schema" }
diff --git a/crates/op-html/src/lib.rs b/crates/op-html/src/lib.rs
new file mode 100644
index 000000000..1a78e72f1
--- /dev/null
+++ b/crates/op-html/src/lib.rs
@@ -0,0 +1,63 @@
+//! HTML → PenNode importer (structured path, CSS-subset cascade).
+
+use jian_ops_schema::node::PenNode;
+
+pub struct HtmlImportOptions {
+ pub viewport_width: f64,
+ pub base_font_size: f64,
+ pub document_name: Option,
+}
+
+impl Default for HtmlImportOptions {
+ fn default() -> Self {
+ Self {
+ viewport_width: 1440.0,
+ base_font_size: 16.0,
+ document_name: None,
+ }
+ }
+}
+
+pub struct HtmlImportResult {
+ pub nodes: Vec,
+ pub warnings: Vec,
+}
+
+pub fn import_html(source: &str, opts: &HtmlImportOptions) -> HtmlImportResult {
+ let _ = opts;
+ let mut warnings = Vec::new();
+ if source.trim().is_empty() {
+ warnings.push("no importable content: input HTML is empty".to_string());
+ return HtmlImportResult {
+ nodes: Vec::new(),
+ warnings,
+ };
+ }
+ // Pipeline lands in later tasks; non-empty input is wired up in Task 11.
+ warnings.push("no importable content: importer pipeline not yet implemented".to_string());
+ HtmlImportResult {
+ nodes: Vec::new(),
+ warnings,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn empty_input_yields_no_nodes_and_a_warning() {
+ let r = import_html("", &HtmlImportOptions::default());
+ assert!(r.nodes.is_empty());
+ assert_eq!(r.warnings.len(), 1);
+ assert!(r.warnings[0].contains("no importable content"));
+ }
+
+ #[test]
+ fn options_default_values() {
+ let o = HtmlImportOptions::default();
+ assert_eq!(o.viewport_width, 1440.0);
+ assert_eq!(o.base_font_size, 16.0);
+ assert!(o.document_name.is_none());
+ }
+}