on master help sign in
Add 10 improvements: include error chains, JSON error positions, explain command, config validation, smart watch mode, watch debounce, port display, and stress tests

- Include error chains: diagnostics from @include/@import show full file path stack - JSON error messages: @data errors now show position info (line:col context) - New 'explain' command: shows what CSS each source line produces with → arrows - Config validation: htmlang.toml warns on unknown keys/sections with line numbers - Dependency-aware watch: only recompiles files whose source or deps changed - Watch debounce: 50ms delay after file change before processing - Port display: watch message now shows actual server port, not hardcoded 3000 - Stress tests: large realistic page (274 directives, 8+ nesting levels) with perf check

commit9ec4406b1d144c518fd4c2eb30a7c4c6b5e36b97 authoriko <mail@iko.soy> dateThu 16 Apr 2026, 01:25 (6 months ago) parent4497b38
5 files changed+907−52
src/main.rs+235−48 src/parser.rs+71−4 tests/snapshots.rs+25 tests/snapshots/stress_deeply_nested.hl+184 tests/snapshots/stress_large.hl+392
src/main.rs+235−48
@@ -381,6 +381,7 @@ Commands:
381381 bundle <file|dir> [-o <out>] Compile and inline all assets as data URIs
382382 size <file|dir> Report output sizes (raw, minified, ~gzip)
383383 benchmark <file|dir> Measure compile time and output size
384+ explain <file.hl> Show what CSS each source line produces
384385 lsp Start the Language Server Protocol server
385386 completions <shell> Generate shell completions (bash, zsh, fish)
386387
@@ -965,13 +966,19 @@ fn load_config(target: &Path) -> ProjectConfig {
965966 };
966967
967968 let mut section = "";
968− for line in content.lines() {
969+ for (line_num, line) in content.lines().enumerate() {
969970 let trimmed = line.trim();
970971 if trimmed.is_empty() || trimmed.starts_with('#') {
971972 continue;
972973 }
973974 if trimmed.starts_with('[') && trimmed.ends_with(']') {
974975 section = &trimmed[1..trimmed.len() - 1];
976+ if !matches!(section, "variables" | "breakpoints") {
977+ eprintln!(
978+ "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints)",
979+ config_path.display(), line_num + 1, section
980+ );
981+ }
975982 continue;
976983 }
977984 if let Some((key, value)) = trimmed.split_once('=') {
@@ -981,7 +988,12 @@ fn load_config(target: &Path) -> ProjectConfig {
981988 "" => match key {
982989 "output" => config.output = Some(value.to_string()),
983990 "port" => config.port = value.parse().unwrap_or(3000),
984− _ => {}
991+ _ => {
992+ eprintln!(
993+ "warning: {}:{}: unknown key '{}' (expected: output, port)",
994+ config_path.display(), line_num + 1, key
995+ );
996+ }
985997 },
986998 "variables" => {
987999 config.variables.push((key.to_string(), value.to_string()));
@@ -989,7 +1001,7 @@ fn load_config(target: &Path) -> ProjectConfig {
9891001 "breakpoints" => {
9901002 config.breakpoints.push((key.to_string(), value.to_string()));
9911003 }
992− _ => {}
1004+ _ => {} // already warned about unknown section
9931005 }
9941006 }
9951007 }
@@ -1632,7 +1644,7 @@ fn main() {
16321644 rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx));
16331645 });
16341646 if serve_open { open_in_browser(effective_port); }
1635− watch_loop(target_path, &hl_files, &[], true, true, Some(tx));
1647+ watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port);
16361648 } else {
16371649 let (_, included) = compile(&target, &CompileConfig {
16381650 dev: true, error_overlay: true,
@@ -1647,7 +1659,7 @@ fn main() {
16471659 });
16481660 if serve_open { open_in_browser(effective_port); }
16491661 let files = vec![PathBuf::from(&target)];
1650− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx));
1662+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx), effective_port);
16511663 }
16521664 return;
16531665 }
@@ -1694,14 +1706,14 @@ fn main() {
16941706 });
16951707 all_included.extend(included);
16961708 }
1697− watch_loop(target_path, &hl_files, &all_included, false, false, None);
1709+ watch_loop(target_path, &hl_files, &all_included, false, false, None, 0);
16981710 } else {
16991711 let (_, included) = compile(&target, &CompileConfig {
17001712 output_path: effective_output.as_deref(),
17011713 ..Default::default()
17021714 });
17031715 let files = vec![PathBuf::from(&target)];
1704− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None);
1716+ watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None, 0);
17051717 }
17061718 return;
17071719 }
@@ -2828,6 +2840,145 @@ compile();
28282840 return;
28292841 }
28302842
2843+ // Handle "explain" subcommand — show CSS mapping for each line
2844+ if args.len() >= 2 && args[1] == "explain" {
2845+ if args.len() < 3 {
2846+ eprintln!("usage: htmlang explain <file.hl>");
2847+ process::exit(1);
2848+ }
2849+ let file = &args[2];
2850+ let input = match fs::read_to_string(file) {
2851+ Ok(s) => s,
2852+ Err(e) => {
2853+ eprintln!("error: {}: {}", file, e);
2854+ process::exit(1);
2855+ }
2856+ };
2857+ let base = Path::new(file).parent();
2858+ let result = htmlang::parser::parse_with_base(&input, base);
2859+ for d in &result.diagnostics {
2860+ let prefix = match d.severity {
2861+ htmlang::parser::Severity::Error => "error",
2862+ htmlang::parser::Severity::Warning => "warning",
2863+ };
2864+ eprintln!("{}: line {}: {}", prefix, d.line, d.message);
2865+ }
2866+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2867+ process::exit(1);
2868+ }
2869+ let html = htmlang::codegen::generate_dev(&result.document);
2870+ // Extract CSS class mappings from <style> block
2871+ let mut class_css: HashMap<String, String> = HashMap::new();
2872+ if let Some(style_start) = html.find("<style>") {
2873+ if let Some(style_end) = html[style_start..].find("</style>") {
2874+ let css_block = &html[style_start + 7..style_start + style_end];
2875+ // Parse each CSS rule: .className { rules }
2876+ let mut pos = 0;
2877+ let css_bytes = css_block.as_bytes();
2878+ while pos < css_bytes.len() {
2879+ if css_bytes[pos] == b'.' {
2880+ let rule_start = pos;
2881+ // Find class name end
2882+ pos += 1;
2883+ while pos < css_bytes.len() && css_bytes[pos] != b' ' && css_bytes[pos] != b'{' && css_bytes[pos] != b':' && css_bytes[pos] != b',' {
2884+ pos += 1;
2885+ }
2886+ let class_name = &css_block[rule_start + 1..pos];
2887+ // Find rule body
2888+ while pos < css_bytes.len() && css_bytes[pos] != b'{' {
2889+ pos += 1;
2890+ }
2891+ if pos < css_bytes.len() {
2892+ pos += 1; // skip '{'
2893+ let body_start = pos;
2894+ let mut depth = 1;
2895+ while pos < css_bytes.len() && depth > 0 {
2896+ if css_bytes[pos] == b'{' { depth += 1; }
2897+ if css_bytes[pos] == b'}' { depth -= 1; }
2898+ pos += 1;
2899+ }
2900+ let body = css_block[body_start..pos.saturating_sub(1)].trim();
2901+ if !body.is_empty() {
2902+ class_css.entry(class_name.to_string())
2903+ .and_modify(|existing| {
2904+ existing.push_str("; ");
2905+ existing.push_str(body);
2906+ })
2907+ .or_insert_with(|| body.to_string());
2908+ }
2909+ }
2910+ } else {
2911+ pos += 1;
2912+ }
2913+ }
2914+ }
2915+ }
2916+ // Extract data-hl-line attributes from dev HTML to map classes to source lines
2917+ let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
2918+ let html_body = if let Some(body_start) = html.find("<body") {
2919+ &html[body_start..]
2920+ } else {
2921+ &html
2922+ };
2923+ // Scan for elements with data-hl-line="N" and class="X"
2924+ // Dev mode outputs: <div class="a" data-hl-line="2" data-hl-el="el">
2925+ let mut scan_pos = 0;
2926+ while scan_pos < html_body.len() {
2927+ // Find each opening tag
2928+ if html_body.as_bytes()[scan_pos] == b'<' && scan_pos + 1 < html_body.len() && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic() {
2929+ // Find end of tag
2930+ let tag_end = html_body[scan_pos..].find('>').map(|p| scan_pos + p).unwrap_or(html_body.len());
2931+ let tag = &html_body[scan_pos..tag_end];
2932+ // Extract data-hl-line
2933+ if let Some(hl_pos) = tag.find("data-hl-line=\"") {
2934+ let after = &tag[hl_pos + 14..];
2935+ if let Some(end) = after.find('"') {
2936+ if let Ok(ln) = after[..end].parse::<usize>() {
2937+ // Extract class
2938+ if let Some(cls_pos) = tag.find("class=\"") {
2939+ let cls_after = &tag[cls_pos + 7..];
2940+ if let Some(cls_end) = cls_after.find('"') {
2941+ for cls in cls_after[..cls_end].split_whitespace() {
2942+ line_classes.entry(ln).or_default().push(cls.to_string());
2943+ }
2944+ }
2945+ }
2946+ }
2947+ }
2948+ }
2949+ scan_pos = tag_end;
2950+ } else {
2951+ scan_pos += 1;
2952+ }
2953+ }
2954+ // Print explanation
2955+ for (line_num, line) in input.lines().enumerate() {
2956+ let ln = line_num + 1;
2957+ let trimmed = line.trim();
2958+ if trimmed.is_empty() || trimmed.starts_with("--") {
2959+ continue;
2960+ }
2961+ print!("{:>4} | {}", ln, line);
2962+ if let Some(classes) = line_classes.get(&ln) {
2963+ let mut css_parts: Vec<String> = Vec::new();
2964+ for cls in classes {
2965+ if let Some(css) = class_css.get(cls) {
2966+ css_parts.push(css.clone());
2967+ }
2968+ }
2969+ if !css_parts.is_empty() {
2970+ println!();
2971+ println!(" → {}", css_parts.join("; "));
2972+ } else {
2973+ println!();
2974+ }
2975+ } else {
2976+ println!();
2977+ }
2978+ }
2979+ return;
2980+ }
2981+
28312982 // Handle "benchmark" subcommand — measure compile time and output size
28322983 if args.len() >= 2 && args[1] == "benchmark" {
28332984 if args.len() < 3 {
@@ -3219,7 +3370,7 @@ compile();
32193370 None
32203371 };
32213372
3222− watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx);
3373+ watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx, port);
32233374 return;
32243375 }
32253376
@@ -3273,6 +3424,7 @@ compile();
32733424 dev,
32743425 serve,
32753426 reload_tx,
3427+ port,
32763428 );
32773429}
32783430
@@ -3336,7 +3488,7 @@ fn print_bash_completions() {
33363488 local cur prev commands
33373489 cur="${{COMP_WORDS[COMP_CWORD]}}"
33383490 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
3339− commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark test lsp completions"
3491+ commands="init new build serve watch check convert fmt sitemap lint stats preview diff export repl feed components deps dead-code deploy playground clean upgrade create-component outline doctor migrate bundle size benchmark explain test lsp completions"
33403492
33413493 if [ "$COMP_CWORD" -eq 1 ]; then
33423494 COMPREPLY=( $(compgen -W "$commands" -- "$cur") )
@@ -3397,6 +3549,7 @@ _htmlang() {{
33973549 'bundle:Inline all assets'
33983550 'size:Report output sizes'
33993551 'benchmark:Measure compile time'
3552+ 'explain:Show CSS mapping per source line'
34003553 'test:Run assertions'
34013554 'lsp:Start LSP server'
34023555 'completions:Generate shell completions'
@@ -3469,6 +3622,7 @@ fn print_fish_completions() {
34693622 ("bundle", "Inline all assets as data URIs"),
34703623 ("size", "Report output sizes"),
34713624 ("benchmark", "Measure compile time"),
3625+ ("explain", "Show CSS mapping per source line"),
34723626 ("test", "Run assertions"),
34733627 ("lsp", "Start LSP server"),
34743628 ("completions", "Generate shell completions"),
@@ -3526,6 +3680,7 @@ fn watch_loop(
35263680 dev: bool,
35273681 serve: bool,
35283682 reload_tx: Option<tokio::sync::broadcast::Sender<()>>,
3683+ serve_port: u16,
35293684) {
35303685 use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
35313686 use std::sync::mpsc;
@@ -3556,13 +3711,15 @@ fn watch_loop(
35563711 let _ = watcher.watch(watch_dir, RecursiveMode::NonRecursive);
35573712
35583713 if serve {
3559− eprintln!("watching for changes at http://127.0.0.1:3000");
3714+ eprintln!("watching for changes at http://127.0.0.1:{}", serve_port);
35603715 } else {
35613716 eprintln!("watching for changes...");
35623717 }
35633718
35643719 // Track content hashes for incremental rebuilds
35653720 let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
3721+ // Dependency map: source file -> list of included/imported files
3722+ let mut dep_map: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
35663723
35673724 fn hash_file(path: &Path) -> Option<u64> {
35683725 let content = fs::read(path).ok()?;
@@ -3571,7 +3728,7 @@ fn watch_loop(
35713728 Some(hasher.finish())
35723729 }
35733730
3574− // Seed initial hashes
3731+ // Seed initial hashes and dependency map
35753732 for file in source_files {
35763733 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
35773734 if let Some(h) = hash_file(&canonical) {
@@ -3587,11 +3744,12 @@ fn watch_loop(
35873744 loop {
35883745 match rx.recv() {
35893746 Ok(_) => {
3590− // Drain additional events (debounce)
3747+ // Drain additional events (debounce) with short delay for rapid saves
3748+ std::thread::sleep(std::time::Duration::from_millis(50));
35913749 while rx.try_recv().is_ok() {}
35923750
3593− // Check if any file content actually changed
3594− let mut any_changed = false;
3751+ // Collect which files actually changed
3752+ let mut changed_files: Vec<PathBuf> = Vec::new();
35953753 let check_path = |path: &Path,
35963754 hashes: &mut HashMap<PathBuf, u64>|
35973755 -> bool {
@@ -3607,7 +3765,18 @@ fn watch_loop(
36073765 for file in source_files {
36083766 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
36093767 if check_path(&canonical, &mut content_hashes) {
3610− any_changed = true;
3768+ changed_files.push(canonical);
3769+ }
3770+ }
3771+
3772+ // Check included files for changes
3773+ for (_, deps) in &dep_map {
3774+ for dep in deps {
3775+ if check_path(dep, &mut content_hashes) {
3776+ if !changed_files.contains(dep) {
3777+ changed_files.push(dep.clone());
3778+ }
3779+ }
36113780 }
36123781 }
36133782
@@ -3617,63 +3786,81 @@ fn watch_loop(
36173786 for file in &current_files {
36183787 let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
36193788 if check_path(&canonical, &mut content_hashes) {
3620− any_changed = true;
3789+ changed_files.push(canonical.clone());
36213790 let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
36223791 }
36233792 }
36243793 }
36253794
3626− if !any_changed {
3795+ if changed_files.is_empty() {
36273796 continue;
36283797 }
36293798
36303799 eprintln!("\nrecompiling...");
36313800
3632− // Dependency-graph incremental: track which source files
3633− // depend on which included files, only recompile affected ones
3634− let changed_paths: Vec<PathBuf> = source_files.iter()
3635− .filter_map(|f| {
3636− let c = fs::canonicalize(f).unwrap_or_else(|_| f.clone());
3637− if content_hashes.get(&c).is_some() { Some(c) } else { None }
3638− })
3639− .collect();
3640−
3641− // Build dependency map: source -> [deps] from previous compile
3642− // (tracked via included_files returned by compile)
3643− // For now, recompile source files whose deps changed
3801+ // Determine which source files need recompilation:
3802+ // 1. Source files that changed directly
3803+ // 2. Source files whose dependencies changed
3804+ let mut files_to_compile: Vec<PathBuf> = Vec::new();
3805+ let all_sources: Vec<PathBuf> = {
3806+ let mut s: Vec<PathBuf> = source_files.iter()
3807+ .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
3808+ .collect();
3809+ if watch_dir.is_dir() {
3810+ for file in &collect_hl_files(watch_dir) {
3811+ let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3812+ if !s.contains(&c) {
3813+ s.push(c);
3814+ }
3815+ }
3816+ }
3817+ s
3818+ };
3819+
3820+ for source in &all_sources {
3821+ // Recompile if the source itself changed
3822+ if changed_files.contains(source) {
3823+ if !files_to_compile.contains(source) {
3824+ files_to_compile.push(source.clone());
3825+ }
3826+ continue;
3827+ }
3828+ // Recompile if any of its dependencies changed
3829+ if let Some(deps) = dep_map.get(source) {
3830+ if deps.iter().any(|d| changed_files.contains(d)) {
3831+ if !files_to_compile.contains(source) {
3832+ files_to_compile.push(source.clone());
3833+ }
3834+ }
3835+ }
3836+ }
3837+
3838+ // If no specific files identified (e.g., first run), compile all
3839+ if files_to_compile.is_empty() {
3840+ files_to_compile = all_sources;
3841+ }
3842+
36443843 let mut recompiled = 0usize;
3645− for file in source_files {
3844+ for file in &files_to_compile {
36463845 let path_str = file.to_string_lossy().to_string();
36473846 let (_, new_includes) = compile(&path_str, &CompileConfig {
36483847 dev, error_overlay: serve,
36493848 ..Default::default()
36503849 });
36513850 recompiled += 1;
3652− for inc in &new_includes {
3851+ // Update dependency map
3852+ let canonical_deps: Vec<PathBuf> = new_includes.iter()
3853+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3854+ .collect();
3855+ for inc in &canonical_deps {
36533856 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
36543857 if let Some(h) = hash_file(inc) {
36553858 content_hashes.insert(inc.clone(), h);
36563859 }
36573860 }
3861+ dep_map.insert(file.clone(), canonical_deps);
36583862 }
36593863
3660− // Also compile any new .hl files in directory mode
3661− if watch_dir.is_dir() {
3662− for file in &collect_hl_files(watch_dir) {
3663− if !source_files.contains(file) {
3664− let path_str = file.to_string_lossy().to_string();
3665− let (_, new_includes) = compile(&path_str, &CompileConfig {
3666− dev, error_overlay: serve,
3667− ..Default::default()
3668− });
3669− recompiled += 1;
3670− for inc in &new_includes {
3671− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3672− }
3673− }
3674− }
3675− }
3676− let _ = changed_paths;
36773864 eprintln!("recompiled {} file(s)", recompiled);
36783865
36793866 if let Some(ref tx) = reload_tx {
src/parser.rs+71−4
@@ -766,6 +766,7 @@ impl Parser {
766766 let saved_base = ctx.base_path.clone();
767767 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
768768
769+ let diag_count_before = ctx.diagnostics.len();
769770 let included_lines = preprocess(&included_text);
770771 let mut included_parser = Parser {
771772 lines: included_lines,
@@ -773,6 +774,12 @@ impl Parser {
773774 };
774775 let nodes = included_parser.parse_children(0, ctx);
775776
777+ // Annotate new diagnostics with include chain
778+ let include_chain = format_include_chain(&ctx.include_stack);
779+ for d in &mut ctx.diagnostics[diag_count_before..] {
780+ d.message = format!("{}\n in {}", d.message, include_chain);
781+ }
782+
776783 ctx.base_path = saved_base;
777784 ctx.include_stack.pop();
778785 return Ok(Some(nodes));
@@ -900,6 +907,8 @@ impl Parser {
900907 let saved_base = ctx.base_path.clone();
901908 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
902909
910+ let diag_count_before = ctx.diagnostics.len();
911+
903912 if let Some(ref prefix) = alias {
904913 // Parse into a temporary context, then copy definitions with prefix
905914 let saved_fns = ctx.functions.clone();
@@ -962,6 +971,12 @@ impl Parser {
962971 let _discarded_nodes = imported_parser.parse_children(0, ctx);
963972 }
964973
974+ // Annotate new diagnostics with import chain
975+ let import_chain = format_include_chain(&ctx.include_stack);
976+ for d in &mut ctx.diagnostics[diag_count_before..] {
977+ d.message = format!("{}\n in {}", d.message, import_chain);
978+ }
979+
965980 ctx.base_path = saved_base;
966981 ctx.include_stack.pop();
967982 ctx.import_stack.pop();
@@ -1220,8 +1235,8 @@ impl Parser {
12201235 }
12211236 };
12221237
1223− match parse_json(&json_text) {
1224− Some(json) => {
1238+ match parse_json_with_error(&json_text) {
1239+ Ok(json) => {
12251240 if prefix.is_empty() {
12261241 // No prefix: top-level object keys become variables directly
12271242 if let JsonValue::Object(pairs) = &json {
@@ -1248,10 +1263,10 @@ impl Parser {
12481263 }
12491264 }
12501265 }
1251− None => {
1266+ Err(detail) => {
12521267 ctx.diagnostics.push(Diagnostic {
12531268 line: line_num,
1254− message: format!("invalid JSON in '{}'", filename),
1269+ message: format!("invalid JSON in '{}': {}", filename, detail),
12551270 severity: Severity::Error,
12561271 source_line: Some(content.clone()),
12571272 });
@@ -2988,6 +3003,15 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
29883003 }
29893004}
29903005
3006+/// Format include/import stack as a readable chain for error messages.
3007+fn format_include_chain(stack: &[PathBuf]) -> String {
3008+ stack
3009+ .iter()
3010+ .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
3011+ .collect::<Vec<_>>()
3012+ .join(" → ")
3013+}
3014+
29913015fn levenshtein(a: &str, b: &str) -> usize {
29923016 let a: Vec<char> = a.chars().collect();
29933017 let b: Vec<char> = b.chars().collect();
@@ -4521,6 +4545,49 @@ fn parse_json(input: &str) -> Option<JsonValue> {
45214545 Some(val)
45224546}
45234547
4548+/// Parse JSON with an error message indicating where parsing failed.
4549+fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
4550+ let trimmed = input.trim();
4551+ if trimmed.is_empty() {
4552+ return Err("empty input".to_string());
4553+ }
4554+ let chars: Vec<char> = trimmed.chars().collect();
4555+ match parse_json_value(&chars, 0) {
4556+ Some((val, _)) => Ok(val),
4557+ None => {
4558+ // Find approximate error position by parsing as far as possible
4559+ let mut deepest = 0usize;
4560+ fn probe(chars: &[char], pos: usize, deepest: &mut usize) {
4561+ let mut p = pos;
4562+ while p < chars.len() {
4563+ if p > *deepest { *deepest = p; }
4564+ match chars[p] {
4565+ '{' | '[' => { p += 1; probe(chars, p, deepest); return; }
4566+ '"' => {
4567+ p += 1;
4568+ while p < chars.len() && chars[p] != '"' {
4569+ if chars[p] == '\\' { p += 1; }
4570+ p += 1;
4571+ }
4572+ if p < chars.len() { p += 1; }
4573+ if p > *deepest { *deepest = p; }
4574+ return;
4575+ }
4576+ _ => { p += 1; }
4577+ }
4578+ }
4579+ }
4580+ probe(&chars, 0, &mut deepest);
4581+ // Convert char position to line:col
4582+ let prefix: String = chars[..deepest.min(chars.len())].iter().collect();
4583+ let line = prefix.chars().filter(|&c| c == '\n').count() + 1;
4584+ let col = prefix.rfind('\n').map_or(prefix.len(), |p| prefix.len() - p - 1) + 1;
4585+ let context: String = chars[deepest.saturating_sub(20)..deepest.min(chars.len())].iter().collect();
4586+ Err(format!("at line {}:{} near \"{}\"", line, col, context.trim()))
4587+ }
4588+ }
4589+}
4590+
45244591fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
45254592 pos = skip_ws(chars, pos);
45264593 if pos >= chars.len() {
tests/snapshots.rs+25
@@ -3897,3 +3897,28 @@ fn auto_image_dimensions_respects_explicit() {
38973897
38983898 let _ = std::fs::remove_dir_all(&dir);
38993899}
3900+
3901+#[test]
3902+fn snapshot_stress_large() {
3903+ snapshot_test("stress_large");
3904+}
3905+
3906+#[test]
3907+fn snapshot_stress_deeply_nested() {
3908+ snapshot_test("stress_deeply_nested");
3909+}
3910+
3911+#[test]
3912+fn perf_large_document() {
3913+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
3914+ let input = std::fs::read_to_string(dir.join("stress_large.hl")).unwrap();
3915+ let start = std::time::Instant::now();
3916+ let result = htmlang::parser::parse(&input);
3917+ let _ = htmlang::codegen::generate(&result.document);
3918+ let elapsed = start.elapsed();
3919+ assert!(
3920+ elapsed.as_millis() < 5000,
3921+ "compilation took {}ms, expected < 5000ms",
3922+ elapsed.as_millis()
3923+ );
3924+}
tests/snapshots/stress_deeply_nested.hlnew file+184
@@ -0,0 +1,184 @@
1+@page Deeply Nested Stress Test
2+
3+@let --primary #3b82f6
4+@let --border #e2e8f0
5+@let --radius 8
6+
7+@fn deep-card $title
8+ @el [background white, dark:background #1e293b, rounded $--radius, padding 16, border 1 $--border]
9+ @text [bold, size 16] $title
10+ @children
11+
12+@fn inner-panel $label
13+ @el [background #f8fafc, dark:background #0f172a, rounded 6, padding 12, margin-top 8]
14+ @text [size 13, color #64748b] $label
15+ @children
16+
17+@column [spacing 20, padding 24]
18+
19+ @el [background #f0f9ff, rounded 12, padding 20, border 1 #bae6fd]
20+ @text [bold, size 20, color #0369a1] Level 1 - App Shell
21+ @row [spacing 16, margin-top 16]
22+ @el [background white, rounded 8, padding 16, border 1 $--border, width fill]
23+ @text [bold, size 16, color #1e293b] Level 2 - Sidebar
24+ @column [spacing 8, margin-top 12]
25+ @el [background #f1f5f9, rounded 6, padding 12]
26+ @text [size 14, color #475569] Level 3 - Nav Group
27+ @column [spacing 4, margin-top 8]
28+ @el [padding 8 12, hover:background #e2e8f0, rounded 4, transition all 0.15s]
29+ @text [size 13, color #64748b] Level 4 - Nav Item
30+ @row [spacing 6, margin-top 4, align-items center]
31+ @el [width 6, height 6, rounded 50%, background #10b981]
32+ @el [padding 4 8, background #dbeafe, rounded 4]
33+ @text [size 11, color #1d4ed8] Level 5 - Badge
34+ @el [display inline-block, margin-left 4]
35+ @text [size 10, color #3b82f6] Level 6 - Badge Detail
36+ @el [margin-top 2]
37+ @text [size 9, color #93c5fd] Level 7 - Sub Detail
38+ @el [padding 2 4, background #eff6ff, rounded 2, margin-top 1]
39+ @text [size 8, color #bfdbfe] Level 8 - Deepest Badge
40+
41+ @el [padding 8 12, hover:background #e2e8f0, rounded 4]
42+ @text [size 13, color #64748b] Level 4 - Second Nav Item
43+ @el [margin-top 4, padding 6, background #f0fdf4, rounded 4]
44+ @text [size 12, color #166534] Level 5 - Status
45+ @row [spacing 4, margin-top 2]
46+ @el [padding 2 6, background #bbf7d0, rounded 2]
47+ @text [size 10, color #15803d] Level 6 - Online
48+ @el [padding 2 6, background #fef3c7, rounded 2]
49+ @text [size 10, color #92400e] Level 6 - Away
50+
51+ @el [background #f1f5f9, rounded 6, padding 12]
52+ @text [size 14, color #475569] Level 3 - Second Group
53+ @column [spacing 4, margin-top 8]
54+ @each $item in Settings,Profile,Security
55+ @el [padding 8 12, hover:background #e2e8f0, rounded 4]
56+ @text [size 13, color #64748b] $item
57+
58+ @el [background white, rounded 8, padding 16, border 1 $--border, width fill]
59+ @text [bold, size 16, color #1e293b] Level 2 - Main Content
60+ @column [spacing 12, margin-top 12]
61+ @deep-card [title Level 3 - Dashboard Card]
62+ @inner-panel [label Level 4 - Chart Area]
63+ @row [spacing 8, margin-top 8]
64+ @el [width fill, background #eff6ff, rounded 4, padding 8]
65+ @text [size 12, color #1d4ed8, bold] Level 5 - Metric A
66+ @el [margin-top 4, height 60, background #dbeafe, rounded 4, padding 8]
67+ @text [size 20, bold, color #1e40af] Level 6 - Value
68+ @el [margin-top 2]
69+ @text [size 10, color #3b82f6] Level 7 - Trend
70+ @row [spacing 2, margin-top 2]
71+ @el [width 4, height 12, background #93c5fd, rounded 1]
72+ @el [width 4, height 18, background #60a5fa, rounded 1]
73+ @el [width 4, height 8, background #93c5fd, rounded 1]
74+ @el [width 4, height 22, background #3b82f6, rounded 1]
75+ @el [width 2, height 2, rounded 50%, background white, margin-top 1]
76+ @text [size 6, color white] Level 8 - Spark Dot
77+ @el [width fill, background #f0fdf4, rounded 4, padding 8]
78+ @text [size 12, color #166534, bold] Level 5 - Metric B
79+ @el [margin-top 4, height 60, background #dcfce7, rounded 4, padding 8]
80+ @text [size 20, bold, color #166534] Level 6 - Value
81+ @el [margin-top 2]
82+ @text [size 10, color #16a34a] Level 7 - Change
83+ @el [padding 1 4, background #bbf7d0, rounded 2, display inline-block]
84+ @text [size 8, color #15803d] Level 8 - Delta
85+
86+ @deep-card [title Level 3 - Form Card]
87+ @inner-panel [label Level 4 - Form Section]
88+ @column [spacing 8, margin-top 8]
89+ @el [padding 8, border 1 $--border, rounded 4]
90+ @text [size 12, color #64748b, margin-bottom 4] Level 5 - Field Group
91+ @input [type text, placeholder Name, padding 6 10, rounded 4, border 1 $--border, width fill]
92+ @el [margin-top 4, padding 4 8, background #fef2f2, rounded 4]
93+ @text [size 11, color #991b1b] Level 6 - Validation Error
94+ @el [margin-top 2]
95+ @text [size 10, color #dc2626] Level 7 - Error Detail
96+ @el [margin-top 1, padding 2 4, background #fee2e2, rounded 2]
97+ @text [size 9, color #b91c1c] Level 8 - Error Code
98+
99+ @el [padding 8, border 1 $--border, rounded 4]
100+ @text [size 12, color #64748b, margin-bottom 4] Level 5 - Select Group
101+ @select [padding 6 10, rounded 4, border 1 $--border, width fill]
102+ @option [value a] Option A
103+ @option [value b] Option B
104+ @option [value c] Option C
105+
106+ @deep-card [title Level 3 - Nested Lists Card]
107+ @inner-panel [label Level 4 - Category Browser]
108+ @column [spacing 6, margin-top 8]
109+ @let top-categories Electronics,Clothing,Books
110+ @each $cat in $top-categories
111+ @el [padding 8, border 1 $--border, rounded 4, hover:background #f8fafc]
112+ @text [size 14, bold, color #1e293b] Level 5 - $cat
113+ @column [spacing 4, margin-top 6, margin-left 12]
114+ @el [padding 6, background #f8fafc, rounded 4]
115+ @row [spacing 8, align-items center]
116+ @el [width 8, height 8, rounded 2, background $--primary]
117+ @text [size 12, color #475569] Level 6 - Subcategory
118+ @el [margin-top 4, margin-left 16]
119+ @text [size 11, color #94a3b8] Level 7 - Item
120+ @el [margin-left 12, margin-top 2, padding 2 6, background #eff6ff, rounded 2]
121+ @text [size 10, color #3b82f6] Level 8 - Variant
122+
123+ @el [background #fefce8, rounded 12, padding 20, border 1 #fde68a, margin-top 20]
124+ @text [bold, size 20, color #854d0e] Level 1 - Settings Panel
125+ @column [spacing 12, margin-top 16]
126+ @el [background white, rounded 8, padding 16, border 1 $--border]
127+ @text [bold, size 16, color #1e293b] Level 2 - Account Settings
128+ @column [spacing 8, margin-top 12]
129+ @el [background #f8fafc, rounded 6, padding 12]
130+ @text [size 14, color #475569] Level 3 - Profile Section
131+ @el [margin-top 8, padding 10, border 1 $--border, rounded 6]
132+ @text [size 13, color #1e293b] Level 4 - Avatar Upload
133+ @el [margin-top 6, width 80, height 80, rounded 50%, background #e2e8f0, display flex, align-items center, justify-content center]
134+ @text [size 24, color #94a3b8, bold] Level 5 - Initials
135+ @el [position absolute, bottom 0, right 0, width 24, height 24, rounded 50%, background $--primary, display flex, align-items center, justify-content center]
136+ @text [size 10, color white] Level 6 - Edit Icon
137+ @el [margin-top 1]
138+ @text [size 8, color #bfdbfe] Level 7 - Tooltip
139+ @el [padding 1 3, background #1e3a5f, rounded 2, margin-top 1]
140+ @text [size 7, color white] Level 8 - Tooltip Arrow
141+
142+ @el [background #f8fafc, rounded 6, padding 12]
143+ @text [size 14, color #475569] Level 3 - Preferences
144+ @column [spacing 6, margin-top 8]
145+ @each $pref in Language,Timezone,Currency
146+ @row [spacing 8, align-items center, padding 6 8, border 1 $--border, rounded 4]
147+ @text [size 13, color #1e293b, width fill] Level 4 - $pref
148+ @el [padding 4 12, background #f1f5f9, rounded 4]
149+ @text [size 12, color #64748b] Level 5 - Current Value
150+
151+ @el [background white, rounded 8, padding 16, border 1 $--border]
152+ @text [bold, size 16, color #1e293b] Level 2 - Notification Preferences
153+ @column [spacing 6, margin-top 12]
154+ @let channels Email,Push,SMS,Slack
155+ @each $channel in $channels
156+ @row [spacing 8, align-items center, padding 8, border-bottom 1 $--border]
157+ @text [size 14, color #1e293b, width fill] $channel Notifications
158+ @el [width 36, height 20, rounded 10, background $--primary, padding 2]
159+ @el [width 16, height 16, rounded 50%, background white, margin-left 16]
160+
161+ @el [background #fdf2f8, rounded 12, padding 20, border 1 #fbcfe8, margin-top 20]
162+ @text [bold, size 20, color #9d174d] Level 1 - Monitoring
163+ @row [spacing 16, margin-top 16]
164+ @each $service in API,Database,Cache,Queue
165+ @el [background white, rounded 8, padding 12, border 1 $--border, width fill]
166+ @text [bold, size 14, color #1e293b] Level 2 - $service
167+ @el [margin-top 8, background #f0fdf4, rounded 6, padding 8]
168+ @text [size 12, color #166534] Level 3 - Healthy
169+ @el [margin-top 4]
170+ @row [spacing 4]
171+ @el [width fill, height 4, background #86efac, rounded 2]
172+ @el [width fill, height 4, background #4ade80, rounded 2]
173+ @el [width fill, height 4, background #22c55e, rounded 2]
174+ @el [width fill, height 4, background #86efac, rounded 2]
175+ @el [margin-top 4]
176+ @text [size 11, color #16a34a] Level 4 - Uptime 99.9%
177+ @el [margin-top 2]
178+ @text [size 10, color #22c55e] Level 5 - Response 42ms
179+ @el [margin-top 2, padding 2 6, background #dcfce7, rounded 2]
180+ @text [size 9, color #15803d] Level 6 - p99 120ms
181+ @el [margin-top 1]
182+ @text [size 8, color #4ade80] Level 7 - Throughput 1.2k/s
183+ @el [padding 1 3, background #bbf7d0, rounded 1, margin-top 1]
184+ @text [size 7, color #166534] Level 8 - Peak 2.4k/s
tests/snapshots/stress_large.hlnew file+392
@@ -0,0 +1,392 @@
1+@page Stress Test Large Document
2+
3+@let --primary #3b82f6
4+@let --secondary #10b981
5+@let --accent #f59e0b
6+@let --danger #ef4444
7+@let --dark #1e293b
8+@let --light #f8fafc
9+@let --gray #64748b
10+@let --border #e2e8f0
11+@let --radius 8
12+@let --gap 16
13+@let --gap-sm 8
14+@let --gap-lg 24
15+
16+@keyframes fade-in
17+ from{opacity:0}to{opacity:1}
18+
19+@keyframes slide-down
20+ 0%{transform:translateY(-10px);opacity:0}100%{transform:translateY(0);opacity:1}
21+
22+@fn icon-button $label $color=var(--primary)
23+ @button [padding 8 16, background $color, hover:background $--dark, color white, rounded 6, border none, cursor pointer, transition all 0.2s, font-size 14]
24+ $label
25+
26+@fn badge $label $variant=blue
27+ @el [display inline-block, padding 2 8, background $variant, color white, rounded 12, font-size 12]
28+ @text $label
29+
30+@fn card $title
31+ @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155, hover:shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease]
32+ @text [bold, size 18, color $--dark, dark:color white] $title
33+ @el [margin-top 8]
34+ @children
35+
36+@fn stat-card $label $value $change
37+ @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155]
38+ @text [size 14, color $--gray] $label
39+ @text [bold, size 28, color $--dark, dark:color white, margin-top 4] $value
40+ @text [size 12, color $--secondary, margin-top 2] $change
41+
42+@fn nav-link $url $label $active=false
43+ @if $active == true
44+ @link [color $--primary, bold, padding 8 12, background #eff6ff, dark:background #1e3a5f, rounded 6] $url $label
45+ @else
46+ @link [color $--gray, hover:color $--primary, padding 8 12, hover:background #f1f5f9, dark:hover:background #334155, rounded 6, transition all 0.15s] $url $label
47+
48+@fn sidebar-section $title
49+ @el [margin-bottom $--gap-lg]
50+ @text [bold, size 12, color $--gray, text-transform uppercase, letter-spacing 1, margin-bottom $--gap-sm] $title
51+ @children
52+
53+@fn avatar $initials $size=32
54+ @el [width $size, height $size, rounded 50%, background $--primary, color white, display flex, align-items center, justify-content center, font-size 14, bold]
55+ @text $initials
56+
57+@fn table-row $col1 $col2 $col3 $col4
58+ @row [spacing 0, padding 12 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b]
59+ @el [width 200]
60+ @text [size 14] $col1
61+ @el [width 150]
62+ @text [size 14, color $--gray] $col2
63+ @el [width 120]
64+ @text [size 14] $col3
65+ @el [width fill]
66+ @text [size 14, color $--gray] $col4
67+
68+@fn breadcrumb-item $url $label $is-last=false
69+ @if $is-last == true
70+ @text [color $--dark, dark:color white, size 14] $label
71+ @else
72+ @row [spacing 8]
73+ @link [color $--gray, hover:color $--primary, size 14] $url $label
74+ @text [color $--border, size 14] /
75+
76+@fn alert $message $type=info
77+ @if $type == info
78+ @el [background #eff6ff, border 1 #bfdbfe, rounded $--radius, padding 12 16]
79+ @text [color #1e40af, size 14] $message
80+ @else if $type == success
81+ @el [background #f0fdf4, border 1 #bbf7d0, rounded $--radius, padding 12 16]
82+ @text [color #166534, size 14] $message
83+ @else if $type == warning
84+ @el [background #fffbeb, border 1 #fde68a, rounded $--radius, padding 12 16]
85+ @text [color #92400e, size 14] $message
86+ @else
87+ @el [background #fef2f2, border 1 #fecaca, rounded $--radius, padding 12 16]
88+ @text [color #991b1b, size 14] $message
89+
90+@fn progress-bar $percent $color=var(--primary)
91+ @el [width fill, height 8, background #e2e8f0, dark:background #334155, rounded 4, overflow hidden]
92+ @el [width $percent%, height fill, background $color, rounded 4]
93+
94+@fn tag $label
95+ @el [display inline-block, padding 4 10, background #f1f5f9, dark:background #334155, color $--gray, dark:color #94a3b8, rounded 4, font-size 12, margin 2]
96+ @text $label
97+
98+@fn notification-dot
99+ @el [width 8, height 8, rounded 50%, background $--danger]
100+
101+@fn divider
102+ @el [width fill, height 1, background $--border, dark:background #334155, margin 8 0]
103+
104+@fn toggle $checked=false
105+ @if $checked == true
106+ @el [width 40, height 22, rounded 11, background $--primary, padding 2, cursor pointer, transition all 0.2s]
107+ @el [width 18, height 18, rounded 50%, background white, margin-left 18, transition all 0.2s]
108+ @else
109+ @el [width 40, height 22, rounded 11, background #cbd5e1, dark:background #475569, padding 2, cursor pointer, transition all 0.2s]
110+ @el [width 18, height 18, rounded 50%, background white, transition all 0.2s]
111+
112+@let nav-items home,products,team,blog,docs,pricing
113+@let categories all,design,development,marketing,analytics
114+@let team-members Alice,Bob,Carol,Dave,Eve,Frank,Grace,Hank
115+@let statuses active,pending,completed,archived
116+@let projects Alpha,Beta,Gamma,Delta,Epsilon
117+
118+@row [spacing 0, min-height 100vh]
119+
120+ @column [width 260, background white, dark:background #0f172a, border-right 1 $--border, dark:border-right 1 #1e293b, padding $--gap, spacing $--gap-sm]
121+
122+ @row [spacing 12, padding 8, align-items center]
123+ @avatar [initials HL, size 36]
124+ @column [spacing 2]
125+ @text [bold, size 16, color $--dark, dark:color white] htmlang
126+ @text [size 12, color $--gray] Dashboard v2.0
127+
128+ @divider
129+
130+ @sidebar-section [title Navigation]
131+ @column [spacing 2]
132+ @nav-link [url /dashboard, label Dashboard, active true]
133+ @nav-link [url /analytics, label Analytics]
134+ @nav-link [url /projects, label Projects]
135+ @nav-link [url /team, label Team]
136+ @nav-link [url /messages, label Messages]
137+ @nav-link [url /calendar, label Calendar]
138+ @nav-link [url /documents, label Documents]
139+ @nav-link [url /settings, label Settings]
140+
141+ @sidebar-section [title Favorites]
142+ @column [spacing 2]
143+ @nav-link [url /project/alpha, label Project Alpha]
144+ @nav-link [url /project/beta, label Project Beta]
145+ @nav-link [url /reports/q4, label Q4 Reports]
146+
147+ @sidebar-section [title Shortcuts]
148+ @column [spacing 4]
149+ @row [spacing 8, align-items center]
150+ @el [width 6, height 6, rounded 50%, background $--secondary]
151+ @text [size 13, color $--gray] Online (12)
152+ @row [spacing 8, align-items center]
153+ @el [width 6, height 6, rounded 50%, background $--accent]
154+ @text [size 13, color $--gray] Away (3)
155+ @row [spacing 8, align-items center]
156+ @el [width 6, height 6, rounded 50%, background #cbd5e1]
157+ @text [size 13, color $--gray] Offline (5)
158+
159+ @el [margin-top auto, padding 12, background #f1f5f9, dark:background #1e293b, rounded $--radius]
160+ @row [spacing 8, align-items center]
161+ @avatar [initials JD, size 28]
162+ @column [spacing 0]
163+ @text [size 13, bold, color $--dark, dark:color white] Jane Doe
164+ @text [size 11, color $--gray] jane@example.com
165+
166+ @column [width fill, background $--light, dark:background #0f172a]
167+
168+ @header [padding 12 24, background white, dark:background #0f172a, border-bottom 1 $--border, dark:border-bottom 1 #1e293b]
169+ @row [spacing 16, align-items center]
170+ @column [spacing 2]
171+ @row [spacing 8]
172+ @breadcrumb-item [url /, label Home]
173+ @breadcrumb-item [url /dashboard, label Dashboard, is-last true]
174+ @text [bold, size 22, color $--dark, dark:color white] Dashboard Overview
175+
176+ @el [width fill]
177+
178+ @row [spacing 12, align-items center]
179+ @input [type search, placeholder Search..., padding 8 12, rounded 6, border 1 $--border, dark:border 1 #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 $--primary, transition all 0.2s]
180+ @el [position relative]
181+ @icon-button [label Alerts]
182+ @el [position absolute, top -2, right -2]
183+ @notification-dot
184+ @icon-button [label Settings, color $--gray]
185+
186+ @el [padding $--gap-lg, overflow-y auto]
187+
188+ @alert [message Welcome back! You have 3 new notifications and 2 pending tasks., type info]
189+
190+ @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row]
191+ @stat-card [label Total Revenue, value $48,295, change +12.5% from last month]
192+ @stat-card [label Active Users, value 2,847, change +8.2% from last week]
193+ @stat-card [label Conversion Rate, value 3.24%, change +0.4% from yesterday]
194+ @stat-card [label Open Tickets, value 142, change -5.1% from last month]
195+
196+ @row [spacing $--gap, margin-top $--gap-lg]
197+
198+ @card [title Revenue Overview]
199+ @el [height 200, background #f8fafc, dark:background #0f172a, rounded 6, margin-top 12, display flex, align-items flex-end, padding 16, gap 8]
200+ @each $height in 40,65,45,80,55,70,90,60,75,85,50,95
201+ @el [width fill, height $height%, background $--primary, hover:background #2563eb, rounded 4 4 0 0, transition all 0.2s, min-width 20]
202+
203+ @card [title Activity Feed]
204+ @column [spacing 12, margin-top 12]
205+ @each $member in $team-members
206+ @row [spacing 12, align-items center, padding 8, hover:background #f8fafc, dark:hover:background #1e293b, rounded 6]
207+ @avatar [initials $member, size 32]
208+ @column [spacing 2, width fill]
209+ @text [size 14, bold, color $--dark, dark:color white] $member
210+ @text [size 12, color $--gray] Updated project files
211+ @text [size 11, color $--gray] 2h ago
212+
213+ @row [spacing $--gap, margin-top $--gap-lg, sm:flex-direction column, lg:flex-direction row]
214+
215+ @column [width fill, spacing $--gap]
216+
217+ @card [title Project Status]
218+ @column [spacing 0, margin-top 12]
219+ @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 1 $--border]
220+ @el [width 200]
221+ @text [bold, size 12, color $--gray, text-transform uppercase] Project
222+ @el [width 150]
223+ @text [bold, size 12, color $--gray, text-transform uppercase] Status
224+ @el [width 120]
225+ @text [bold, size 12, color $--gray, text-transform uppercase] Progress
226+ @el [width fill]
227+ @text [bold, size 12, color $--gray, text-transform uppercase] Due Date
228+
229+ @table-row [col1 Project Alpha, col2 In Progress, col3 75%, col4 Dec 15 2025]
230+ @table-row [col1 Project Beta, col2 Review, col3 90%, col4 Nov 30 2025]
231+ @table-row [col1 Project Gamma, col2 Planning, col3 20%, col4 Jan 20 2026]
232+ @table-row [col1 Project Delta, col2 Completed, col3 100%, col4 Oct 10 2025]
233+ @table-row [col1 Project Epsilon, col2 On Hold, col3 45%, col4 Feb 28 2026]
234+
235+ @card [title Progress Breakdown]
236+ @column [spacing 12, margin-top 12]
237+ @each $project in $projects
238+ @column [spacing 4]
239+ @row [spacing 0]
240+ @text [size 13, color $--dark, dark:color white, width fill] $project
241+ @text [size 13, color $--gray] 67%
242+ @progress-bar [percent 67, color var(--primary)]
243+
244+ @card [title Recent Activity Log]
245+ @column [spacing 8, margin-top 12]
246+ @let log-entries Deployed v2.3.1 to production,Merged feature/auth-redesign,Closed issue #482: Login timeout,Updated CI/CD pipeline config,Added unit tests for payment module,Reviewed PR #156: API refactor,Fixed memory leak in worker pool,Released hotfix 2.3.2
247+ @each $entry, $i in $log-entries
248+ @row [spacing 12, padding 8 12, align-items center]
249+ @el [width 24, height 24, rounded 50%, background #eff6ff, dark:background #1e3a5f, display flex, align-items center, justify-content center]
250+ @text [size 10, color $--primary, bold] $i
251+ @text [size 14, color $--dark, dark:color white, width fill] $entry
252+ @text [size 11, color $--gray] just now
253+
254+ @column [width 340, sm:width fill, spacing $--gap]
255+
256+ @card [title Quick Actions]
257+ @column [spacing 8, margin-top 12]
258+ @icon-button [label New Project, color var(--primary)]
259+ @icon-button [label Create Report, color var(--secondary)]
260+ @icon-button [label Invite Member, color var(--accent)]
261+ @icon-button [label View Backlog, color var(--gray)]
262+
263+ @card [title Team Members]
264+ @column [spacing 8, margin-top 12]
265+ @each $member in $team-members
266+ @row [spacing 10, align-items center, padding 6 8, hover:background #f1f5f9, dark:hover:background #1e293b, rounded 6, transition all 0.15s]
267+ @avatar [initials $member, size 28]
268+ @column [spacing 0, width fill]
269+ @text [size 13, bold, color $--dark, dark:color white] $member
270+ @text [size 11, color $--gray] Developer
271+ @el [width 8, height 8, rounded 50%, background $--secondary]
272+
273+ @card [title Tags]
274+ @row [spacing 0, wrap, margin-top 8]
275+ @tag [label frontend]
276+ @tag [label backend]
277+ @tag [label devops]
278+ @tag [label design]
279+ @tag [label mobile]
280+ @tag [label security]
281+ @tag [label performance]
282+ @tag [label testing]
283+ @tag [label docs]
284+ @tag [label infra]
285+ @tag [label ux]
286+ @tag [label api]
287+
288+ @card [title Notifications]
289+ @column [spacing 0, margin-top 12]
290+ @let notifications New deployment completed,PR review requested,Build failed on staging,Meeting in 30 minutes,Subscription renewal due
291+ @each $note in $notifications
292+ @row [spacing 10, padding 10 12, border-bottom 1 $--border, dark:border-bottom 1 #334155, align-items center]
293+ @notification-dot
294+ @text [size 13, color $--dark, dark:color white, width fill] $note
295+ @text [size 11, color $--gray] now
296+
297+ @card [title Settings]
298+ @column [spacing 12, margin-top 12]
299+ @row [spacing 0, align-items center]
300+ @text [size 14, color $--dark, dark:color white, width fill] Dark Mode
301+ @toggle [checked true]
302+ @row [spacing 0, align-items center]
303+ @text [size 14, color $--dark, dark:color white, width fill] Notifications
304+ @toggle [checked true]
305+ @row [spacing 0, align-items center]
306+ @text [size 14, color $--dark, dark:color white, width fill] Auto-save
307+ @toggle [checked false]
308+ @row [spacing 0, align-items center]
309+ @text [size 14, color $--dark, dark:color white, width fill] Compact View
310+ @toggle [checked false]
311+
312+ @el [margin-top $--gap-lg]
313+ @card [title Data Table]
314+ @column [spacing 0, margin-top 12]
315+ @row [spacing 16, margin-bottom 12, align-items center]
316+ @input [type search, placeholder Filter results..., padding 8 12, rounded 6, border 1 $--border, width 240]
317+ @el [width fill]
318+ @row [spacing 8]
319+ @each $cat in $categories
320+ @button [padding 6 12, rounded 4, border 1 $--border, background white, dark:background #1e293b, hover:background #f1f5f9, color $--gray, hover:color $--dark, font-size 13, cursor pointer, transition all 0.15s] $cat
321+
322+ @row [spacing 0, padding 8 16, background #f8fafc, dark:background #0f172a, border-bottom 2 $--border, rounded 6 6 0 0]
323+ @el [width 60]
324+ @text [bold, size 12, color $--gray, text-transform uppercase] ID
325+ @el [width 200]
326+ @text [bold, size 12, color $--gray, text-transform uppercase] Name
327+ @el [width 150]
328+ @text [bold, size 12, color $--gray, text-transform uppercase] Category
329+ @el [width 120]
330+ @text [bold, size 12, color $--gray, text-transform uppercase] Status
331+ @el [width fill]
332+ @text [bold, size 12, color $--gray, text-transform uppercase] Updated
333+
334+ @each $status in $statuses
335+ @each $project in $projects
336+ @row [spacing 0, padding 10 16, border-bottom 1 $--border, dark:border-bottom 1 #334155, hover:background #f8fafc, dark:hover:background #1e293b, cursor pointer, transition all 0.1s]
337+ @el [width 60]
338+ @text [size 13, color $--gray] #42
339+ @el [width 200]
340+ @row [spacing 8, align-items center]
341+ @avatar [initials $project, size 24]
342+ @text [size 14, color $--dark, dark:color white] $project
343+ @el [width 150]
344+ @text [size 13, color $--gray] $status
345+ @el [width 120]
346+ @badge [label $status, variant var(--secondary)]
347+ @el [width fill]
348+ @text [size 13, color $--gray] 2 hours ago
349+
350+ @row [spacing 16, padding 12 16, align-items center, border-top 1 $--border]
351+ @text [size 13, color $--gray] Showing 1-20 of 142 results
352+ @el [width fill]
353+ @row [spacing 4]
354+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Prev
355+ @button [padding 6 10, rounded 4, border 1 $--primary, background $--primary, color white, font-size 13, cursor pointer] 1
356+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 2
357+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] 3
358+ @button [padding 6 10, rounded 4, border 1 $--border, background white, dark:background #1e293b, color $--gray, font-size 13, cursor pointer] Next
359+
360+ @footer [padding $--gap-lg 24, background white, dark:background #0f172a, border-top 1 $--border, dark:border-top 1 #1e293b]
361+ @row [spacing $--gap-lg, sm:flex-direction column, lg:flex-direction row]
362+ @column [spacing 8, width fill]
363+ @text [bold, size 14, color $--dark, dark:color white] htmlang Dashboard
364+ @text [size 12, color $--gray] Built with htmlang - a declarative HTML language
365+ @text [size 11, color $--gray] Copyright 2025 htmlang contributors
366+
367+ @column [spacing 4]
368+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Product
369+ @link [color $--gray, hover:color $--primary, size 13] /features Features
370+ @link [color $--gray, hover:color $--primary, size 13] /pricing Pricing
371+ @link [color $--gray, hover:color $--primary, size 13] /changelog Changelog
372+ @link [color $--gray, hover:color $--primary, size 13] /roadmap Roadmap
373+
374+ @column [spacing 4]
375+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Resources
376+ @link [color $--gray, hover:color $--primary, size 13] /docs Documentation
377+ @link [color $--gray, hover:color $--primary, size 13] /api API Reference
378+ @link [color $--gray, hover:color $--primary, size 13] /guides Guides
379+ @link [color $--gray, hover:color $--primary, size 13] /examples Examples
380+
381+ @column [spacing 4]
382+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Company
383+ @link [color $--gray, hover:color $--primary, size 13] /about About
384+ @link [color $--gray, hover:color $--primary, size 13] /blog Blog
385+ @link [color $--gray, hover:color $--primary, size 13] /careers Careers
386+ @link [color $--gray, hover:color $--primary, size 13] /contact Contact
387+
388+ @column [spacing 4]
389+ @text [bold, size 12, color $--gray, text-transform uppercase, margin-bottom 4] Legal
390+ @link [color $--gray, hover:color $--primary, size 13] /privacy Privacy Policy
391+ @link [color $--gray, hover:color $--primary, size 13] /terms Terms of Service
392+ @link [color $--gray, hover:color $--primary, size 13] /cookies Cookie Policy