diff --git a/build.zig b/build.zig index 3cb61b174..109ccb572 100644 --- a/build.zig +++ b/build.zig @@ -104,6 +104,65 @@ test "service archive support matches ScriptC localized object formats" { try std.testing.expect(app_build.serviceArchiveSupported(windows_host, native_windows_msvc)); } +test "root TypeScript markup discovery classification and resolver budgets" { + const app_build = @import("build/app.zig"); + try std.testing.expect(app_build.isRootMarkupSourcePath("components/card.native")); + try std.testing.expect(app_build.isRootMarkupSourcePath("feature/nested/panel.native")); + try std.testing.expect(!app_build.isRootMarkupSourcePath("app.native")); + try std.testing.expect(!app_build.isRootMarkupSourcePath("windows/settings.native")); + try std.testing.expect(!app_build.isRootMarkupSourcePath("components/card.ts")); + + // The authored paths are relative to src/, while native check and the + // desktop hot-reload resolver see the leading `src/` too. Pin the exact + // source-relative boundaries that keep those full paths within 24 + // segments and 200 bytes. + const max_segments_path = "a/" ** 22 ++ "a"; + try std.testing.expect(app_build.markupSourcePathWithinBudget(max_segments_path)); + try std.testing.expect(!app_build.markupSourcePathWithinBudget(max_segments_path ++ "/a")); + try std.testing.expect(app_build.markupSourcePathWithinBudget("a" ** 196)); + try std.testing.expect(!app_build.markupSourcePathWithinBudget("a" ** 197)); +} + +test "generated TypeScript runners install the compiled root markup view" { + const desktop = @embedFile("src/app_runner/ts_core_main.zig"); + try std.testing.expect(std.mem.indexOf(u8, desktop, "TsUiAppWithFeatures(core, .{ .runtime_markup = dev })") != null); + try std.testing.expect(std.mem.indexOf(u8, desktop, "if (dev) void else @import(\"app_markup_root\")") != null); + try std.testing.expect(std.mem.indexOf(u8, desktop, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null); + try std.testing.expect(std.mem.indexOf(u8, desktop, ".view = CompiledAppView.build") != null); + + const mobile = @embedFile("src/app_runner/ts_core_mobile.zig"); + try std.testing.expect(std.mem.indexOf(u8, mobile, "pub const features: native_sdk.UiAppFeatures = .{ .runtime_markup = false }") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, "TsUiAppWithFeatures(core, features)") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, "const app_markup_root = @import(\"app_markup_root\")") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, "CompiledMarkupImports(core.Model, core.Msg, \"app.native\", &app_markup_sources)") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, ".view = CompiledAppView.build") != null); + try std.testing.expect(std.mem.indexOf(u8, mobile, ".markup =") == null); +} + +test "Debug TypeScript root markup stays outside the staged app module" { + const source = @embedFile("build/app.zig"); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "_ = staged.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");", + ) == null); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "_ = release_markup.addCopyFile(b.path(appPath(b, app_root, \"src/app.native\")), \"app.native\");", + ) != null); + try std.testing.expect(std.mem.indexOf(u8, source, "if (optimize != .Debug)") != null); +} + +test "native check preserves the app markup root for component files" { + const source = @embedFile("tools/native-sdk/main.zig"); + try std.testing.expect(std.mem.indexOf( + u8, + source, + "checkFiles(allocator, io, markup_files.items, .{ .import_root = \"src\" })", + ) != null); +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const host_target = b.graph.host; @@ -397,6 +456,13 @@ pub fn build(b: *std.Build) void { const evals_cmdview_mod = module(b, target, optimize, "evals/harness-lib/cmdview.zig"); const evals_cmdview_tests = testArtifact(b, evals_cmdview_mod); const build_graph_tests = testArtifact(b, module(b, host_target, optimize, "build.zig")); + const invalid_import_compile_mod = module(b, target, optimize, "tests/ts-core/invalid_import_compile.zig"); + invalid_import_compile_mod.addImport("canvas", canvas_mod); + const invalid_import_compile = b.addObject(.{ + .name = "ts-invalid-import-compile", + .root_module = invalid_import_compile_mod, + }); + invalid_import_compile.expect_errors = .{ .contains = "imported files define templates only - this file has a view root element; move the view to its own file and import just the templates" }; // `native version` names the commit the binary was built from, so // binary/framework skew ("your native binary may be stale") is a @@ -621,6 +687,7 @@ pub fn build(b: *std.Build) void { }; const test_step = b.step("test", "Run package and framework tests"); + test_step.dependOn(&invalid_import_compile.step); test_step.dependOn(&b.addRunArtifact(build_graph_tests).step); test_step.dependOn(&b.addRunArtifact(geometry_tests).step); test_step.dependOn(&b.addRunArtifact(assets_tests).step); @@ -665,6 +732,10 @@ pub fn build(b: *std.Build) void { const ai_chat_e2e_run = b.addRunArtifact(ts_core_artifacts.ai_chat); const feed_reader_e2e_run = b.addRunArtifact(ts_core_artifacts.feed_reader); const services_e2e_run = b.addRunArtifact(ts_core_artifacts.services); + const markup_components_e2e_step = b.step("test-ts-markup-components-e2e", "Run root component-file compiled, interpreter, automation, and replay coverage"); + markup_components_e2e_step.dependOn(&markup_e2e_run.step); + markup_components_e2e_step.dependOn(&kanban_e2e_run.step); + markup_components_e2e_step.dependOn(&invalid_import_compile.step); ts_services_e2e_step.dependOn(&feed_reader_e2e_run.step); ts_services_e2e_step.dependOn(&services_e2e_run.step); // The same fixture through the in-process carrier (ServicePool over @@ -2400,12 +2471,12 @@ pub fn build(b: *std.Build) void { \\provenance="$("$cli" automate provenance kanban-canvas "$button_id" 2>/dev/null)" \\case "$provenance" in *"authored=markup"*"root=src/app.native"*) ;; *) echo "writeback smoke: button provenance was not markup-authored: $provenance" >&2; exit 1 ;; esac \\case "$provenance" in *"node file=src/app.native"*) ;; *) echo "writeback smoke: button provenance named the wrong file: $provenance" >&2; exit 1 ;; esac - \\# 2. Loop provenance: the boot view is deliberately self-contained, - \\# so a card title reports its node in app.native plus its iteration key. + \\# 2. Imported-loop provenance: a card title reports its authored + \\# component file plus its iteration key. \\card_id="$(printf '%s\n' "$snapshot" | sed -n 's/.*widget @w1\/kanban-canvas#\([0-9][0-9]*\) role=text name="Retry failed agent runs".*/\1/p' | head -n 1)" \\case "$card_id" in ''|*[!0-9]*) echo "writeback smoke: card text id was missing from the snapshot" >&2; exit 1 ;; esac \\card_provenance="$("$cli" automate provenance kanban-canvas "$card_id" 2>/dev/null)" - \\case "$card_provenance" in *"node file=src/app.native"*) ;; *) echo "writeback smoke: card provenance named the wrong file: $card_provenance" >&2; exit 1 ;; esac + \\case "$card_provenance" in *"node file=src/components/board-column.native"*) ;; *) echo "writeback smoke: card provenance named the wrong file: $card_provenance" >&2; exit 1 ;; esac \\case "$card_provenance" in *"keys="*) ;; *) echo "writeback smoke: card provenance missed the iteration key: $card_provenance" >&2; exit 1 ;; esac \\# 3. Write-back: flip the Todo heading through the verb; the app's own \\# hot-reload watch picks the file change up and repaints. @@ -3360,9 +3431,17 @@ fn tsCoreE2eArtifact( e2e_mod.addImport("native_sdk", desktop_mod); e2e_mod.addImport("ts_core_fixture", fixture_mod); - // The markup battery: the .native view + automation + record/replay - // guarantees over the markup fixture's compiled core. - const markup_e2e_mod = module(b, target, optimize, "tests/ts-core/markup_e2e_tests.zig"); + // The markup battery: the imported .native view + automation + + // record/replay guarantees over the markup fixture's compiled core. + const markup_view_stage = b.addWriteFiles(); + const markup_view_root = markup_view_stage.addCopyFile(b.path("tests/ts-core/markup_e2e_tests.zig"), "markup_e2e_tests.zig"); + _ = markup_view_stage.addCopyFile(b.path("tests/ts-core/markup_view.native"), "markup_view.native"); + _ = markup_view_stage.addCopyFile(b.path("tests/ts-core/components/actions.native"), "components/actions.native"); + const markup_e2e_mod = b.createModule(.{ + .root_source_file = markup_view_root, + .target = target, + .optimize = optimize, + }); markup_e2e_mod.addImport("native_sdk", desktop_mod); markup_e2e_mod.addImport("ts_markup_fixture", markup_fixture_mod); @@ -3378,6 +3457,7 @@ fn tsCoreE2eArtifact( const kanban_stage = b.addWriteFiles(); const kanban_root = kanban_stage.addCopyFile(b.path("tests/ts-core/kanban_e2e_tests.zig"), "kanban_e2e_tests.zig"); _ = kanban_stage.addCopyFile(b.path("examples/kanban/src/app.native"), "app.native"); + _ = kanban_stage.addCopyFile(b.path("examples/kanban/src/components/board-column.native"), "components/board-column.native"); const kanban_mod = b.createModule(.{ .root_source_file = kanban_root, .target = target, diff --git a/build/app.zig b/build/app.zig index 7590a9e0e..ee1fad910 100644 --- a/build/app.zig +++ b/build/app.zig @@ -201,7 +201,7 @@ const TsWindowView = struct { staged_path: []const u8, }; -const TsWindowSource = struct { +const TsMarkupSource = struct { set_path: []const u8, source_path: []const u8, staged_path: []const u8, @@ -209,9 +209,52 @@ const TsWindowSource = struct { const TsWindowViews = struct { views: []const TsWindowView, - sources: []const TsWindowSource, + sources: []const TsMarkupSource, }; +const TsAppMarkupSources = struct { + /// Non-root, non-window files discovered under src/. Window files are + /// staged by the existing secondary-window registry and must not be + /// copied a second time. + files: []const TsMarkupSource, + /// The root view's embedded resolver set. Its path keys are relative to + /// src/, matching the root disk watcher; existing window sources are + /// projected under their staged `windows/...` names. + sources: []const TsMarkupSource, +}; + +const max_markup_source_path_len = 200; +const max_markup_source_path_segments = 24; +const root_markup_source_prefix = "src/"; + +/// Whether a path relative to `src/` fits the markup resolver once the +/// generated desktop watch and `native check` address it as `src/`. +/// The compiled source set drops that prefix, but accepting a path only the +/// compiled resolver can represent would make development and release +/// disagree at the boundary. +pub fn markupSourcePathWithinBudget(path: []const u8) bool { + var segments: usize = 1; // the disk resolver's leading `src` segment + var it = std.mem.tokenizeScalar(u8, path, '/'); + while (it.next() != null) segments += 1; + return root_markup_source_prefix.len + path.len <= max_markup_source_path_len and + segments <= max_markup_source_path_segments; +} + +fn validateMarkupSourcePath(path: []const u8) void { + if (!markupSourcePathWithinBudget(path)) { + std.debug.panic( + "\nTypeScript markup source path `src/{s}` exceeds the import resolver budget: the full app-relative path, including `src/`, must be at most {d} bytes and {d} segments\n", + .{ path, max_markup_source_path_len, max_markup_source_path_segments }, + ); + } +} + +pub fn isRootMarkupSourcePath(path: []const u8) bool { + return std.mem.endsWith(u8, path, ".native") and + !std.mem.eql(u8, path, "app.native") and + !std.mem.startsWith(u8, path, "windows/"); +} + /// Default TypeScript secondary-window views are statically discovered under /// `src/windows/`: `settings.native` serves descriptor label `settings`. /// Direct files form the generated launcher's closed, comptime-compiled view @@ -224,7 +267,7 @@ fn collectTsWindowViews(b: *std.Build, app_root: []const u8) TsWindowViews { var walker = dir.walk(b.allocator) catch return .{ .views = &.{}, .sources = &.{} }; defer walker.deinit(); var views: std.ArrayList(TsWindowView) = .empty; - var sources: std.ArrayList(TsWindowSource) = .empty; + var sources: std.ArrayList(TsMarkupSource) = .empty; while (walker.next(b.graph.io) catch null) |entry| { if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".native")) continue; const normalized_path = b.dupe(entry.path); @@ -261,14 +304,85 @@ fn collectTsWindowViews(b: *std.Build, app_root: []const u8) TsWindowViews { }.than; std.mem.sort(TsWindowView, views.items, {}, less); const source_less = struct { - fn than(_: void, a: TsWindowSource, z: TsWindowSource) bool { + fn than(_: void, a: TsMarkupSource, z: TsMarkupSource) bool { return std.mem.order(u8, a.set_path, z.set_path) == .lt; } }.than; - std.mem.sort(TsWindowSource, sources.items, {}, source_less); + std.mem.sort(TsMarkupSource, sources.items, {}, source_less); return .{ .views = views.items, .sources = sources.items }; } +/// The root TypeScript view resolves imports from every `.native` file under +/// `src/` except itself. Window files already come from collectTsWindowViews; +/// append those under `windows/...` keys so this set matches the root disk +/// resolver without changing the separate window resolver root. +fn collectAppMarkupSources(b: *std.Build, app_root: []const u8, window_views: TsWindowViews) TsAppMarkupSources { + const src_path = appPath(b, app_root, "src"); + var dir = b.build_root.handle.openDir(b.graph.io, src_path, .{ .iterate = true }) catch + return .{ .files = &.{}, .sources = &.{} }; + defer dir.close(b.graph.io); + var walker = dir.walk(b.allocator) catch return .{ .files = &.{}, .sources = &.{} }; + defer walker.deinit(); + + var files: std.ArrayList(TsMarkupSource) = .empty; + while (walker.next(b.graph.io) catch null) |entry| { + if (entry.kind != .file or !std.mem.endsWith(u8, entry.path, ".native")) continue; + const normalized_path = b.dupe(entry.path); + for (normalized_path) |*char| { + if (char.* == '\\') char.* = '/'; + } + if (!isRootMarkupSourcePath(normalized_path)) continue; + validateMarkupSourcePath(normalized_path); + files.append(b.allocator, .{ + .set_path = normalized_path, + .source_path = b.fmt("src/{s}", .{normalized_path}), + .staged_path = normalized_path, + }) catch @panic("OOM"); + } + + const less = struct { + fn than(_: void, a: TsMarkupSource, z: TsMarkupSource) bool { + return std.mem.order(u8, a.set_path, z.set_path) == .lt; + } + }.than; + std.mem.sort(TsMarkupSource, files.items, {}, less); + + var sources: std.ArrayList(TsMarkupSource) = .empty; + sources.ensureTotalCapacity(b.allocator, files.items.len + window_views.sources.len) catch @panic("OOM"); + sources.appendSliceAssumeCapacity(files.items); + for (window_views.sources) |source| { + validateMarkupSourcePath(source.staged_path); + sources.appendAssumeCapacity(.{ + .set_path = source.staged_path, + .source_path = source.source_path, + .staged_path = source.staged_path, + }); + } + std.mem.sort(TsMarkupSource, sources.items, {}, less); + return .{ .files = files.items, .sources = sources.items }; +} + +fn tsAppMarkupSourcesSource(b: *std.Build, registry: TsAppMarkupSources) []const u8 { + var out: std.ArrayList(u8) = .empty; + out.appendSlice(b.allocator, + \\//! Generated by build/app.zig from src/**/*.native. + \\const native_sdk = @import("native_sdk"); + \\const canvas = native_sdk.canvas; + \\pub const sources = [_]canvas.ui_markup.SourceFile{ + \\ + ) catch @panic("OOM"); + for (registry.sources) |source| { + const line = std.fmt.allocPrint( + b.allocator, + " .{{ .path = \"{f}\", .source = @embedFile(\"{f}\") }},\n", + .{ std.zig.fmtString(source.set_path), std.zig.fmtString(source.staged_path) }, + ) catch @panic("OOM"); + out.appendSlice(b.allocator, line) catch @panic("OOM"); + } + out.appendSlice(b.allocator, "};\n") catch @panic("OOM"); + return out.items; +} + fn tsWindowRegistrySource(b: *std.Build, registry: TsWindowViews) []const u8 { const views = registry.views; var out: std.ArrayList(u8) = .empty; @@ -369,6 +483,10 @@ const TsCoreStage = struct { /// mirror/registry files): the embed static library's `app` /// module roots here on iOS/Android targets. mobile_root: std.Build.LazyPath, + /// Release/mobile-only module embedding src/app.native. It lives in a + /// separate generated directory so a Debug app-code module never gains + /// the root markup as a transitive file input; Debug links markup_c. + app_markup_root: std.Build.LazyPath, /// The compiled-core archive: the app module links it (with libc, /// for the toolchain's runtime) beside the staged mirror. archive: std.Build.LazyPath, @@ -860,6 +978,7 @@ fn tsCoreStage( ) TsCoreStage { const node = tsCorePreflight(b, dep, app_root); const window_views = collectTsWindowViews(b, app_root); + const app_markup_sources = collectAppMarkupSources(b, app_root, window_views); const has_services = appHasServiceFiles(b, app_root); if (!scriptcCompileSupported(b.graph.host.result, target)) { panicUnsupportedScriptcTarget(b, b.graph.host.result, target); @@ -1195,9 +1314,19 @@ fn tsCoreStage( \\ , .{ service_carrier, service_pool_workers })); _ = staged.addCopyFile(migrations_zig, "migrations.zig"); + // Every imported file is an input to Debug's embedded source resolver and + // the release/mobile compiled view, so stage those beside the runner. + // Keep the ROOT out of this directory: changing any file in one WriteFiles + // output changes its directory identity, and the runner lives here. Root + // markup gets a separate release-only module below; Debug keeps the small + // linked C data object, preserving its link-only edit path. + for (app_markup_sources.files) |source| { + _ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path); + } // Primary markup bytes live in their own tiny C translation unit. They - // no longer participate in Zig source analysis, so a markup edit reuses - // the compiled app/SDK graph and performs only C data compile + relink. + // drive Debug desktop without participating in Zig source analysis, so a + // root markup edit there reuses the compiled app/SDK graph and performs + // only C data compile + relink. const markup_embed = b.addSystemCommand(&.{node}); markup_embed.setName("native embed primary markup data"); markup_embed.addFileArg(dep.path("packages/core/scripts/embed_markup_c.mjs")); @@ -1206,15 +1335,24 @@ fn tsCoreStage( for (window_views.sources) |source| { _ = staged.addCopyFile(b.path(appPath(b, app_root, source.source_path)), source.staged_path); } + _ = staged.add("app_sources.zig", tsAppMarkupSourcesSource(b, app_markup_sources)); _ = staged.add("window_views.zig", tsWindowRegistrySource(b, window_views)); const main_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_main.zig"), "main.zig"); // The mobile wiring stages beside the desktop entry: same mirror, same // registry, same carrier constant — only the shell differs (the embed // host's AppDef contract instead of a process `main`). const mobile_root = staged.addCopyFile(dep.path("src/app_runner/ts_core_mobile.zig"), "mobile.zig"); + const release_markup = b.addWriteFiles(); + _ = release_markup.addCopyFile(b.path(appPath(b, app_root, "src/app.native")), "app.native"); + const app_markup_root = release_markup.add("app_markup_root.zig", + \\//! Generated release/mobile embedding for the TypeScript root view. + \\pub const source = @embedFile("app.native"); + \\ + ); return .{ .main_root = main_root, .mobile_root = mobile_root, + .app_markup_root = app_markup_root, .archive = archive, .service_exe = service_exe, .service_archive = service_archive, @@ -1453,11 +1591,12 @@ pub const MobileLibOptions = struct { pub const MobileTsCore = struct { /// The staged mobile wiring (mobile.zig beside the generated mirror). main_root: std.Build.LazyPath, + /// Separate root-markup module consumed by the compiled mobile view. + app_markup_root: std.Build.LazyPath, /// The compiled-core archive; merged into the embed library. archive: std.Build.LazyPath, /// The in-process service archive, when src/services exists. service_archive: ?std.Build.LazyPath, - markup_c: std.Build.LazyPath, /// The app.zon module the mobile wiring reads scene chrome, identity, /// and theme from (`app_manifest_zon`). manifest_mod: *std.Build.Module, @@ -1522,6 +1661,11 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std .optimize = optimize, }); mod.addImport("app_manifest_zon", ts.manifest_mod); + mod.addImport("app_markup_root", b.createModule(.{ + .root_source_file = ts.app_markup_root, + .target = target, + .optimize = optimize, + })); break :ts_app mod; } else localModule(b, target, optimize, options.main); app_mod.addImport("native_sdk", native_sdk_mod); @@ -1536,7 +1680,6 @@ fn addMobileLibWithTarget(b: *std.Build, dep: *std.Build.Dependency, target: std // host link already passes). exports_mod.link_libc = true; exports_mod.addObjectFile(ts.archive); - exports_mod.addObjectFile(markupDataObject(b, target, optimize, ts.markup_c).getEmittedBin()); if (ts.service_archive) |service_archive| exports_mod.addObjectFile(service_archive); } if (options.store_capability or options.relational_capability) { @@ -1712,9 +1855,9 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A .max_image_pixel_bytes = app_config.max_image_pixel_bytes, .ts_core = if (ts_stage) |stage| .{ .main_root = stage.mobile_root, + .app_markup_root = stage.app_markup_root, .archive = stage.archive, .service_archive = stage.service_archive, - .markup_c = stage.markup_c, .manifest_mod = appManifestModule(b, app_options.app_root, manifest_name), } else null, }); @@ -1779,7 +1922,7 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A }); const link_mod = b.createModule(.{ .target = target, .optimize = app_optimize }); link_mod.addObject(app_code); - link_mod.addObject(markupDataObject(b, target, app_optimize, stage.markup_c)); + if (app_optimize == .Debug) link_mod.addObject(markupDataObject(b, target, app_optimize, stage.markup_c)); // Object dependencies propagate framework/system-library NAMES, but // Zig does not propagate the search paths or rpaths recorded on the // module that produced an object. Restate those path-only facts on @@ -1849,7 +1992,9 @@ pub fn addAppArtifacts(b: *std.Build, dep: *std.Build.Dependency, app_options: A appModule(b, dep, target, optimize, app_options, manifest_name, options_mod, ts_stage, relational_migrations, app_config) else app_mod; - if (ts_stage) |stage| test_app_mod.addObject(markupDataObject(b, target, optimize, stage.markup_c)); + if (ts_stage) |stage| { + if (optimize == .Debug) test_app_mod.addObject(markupDataObject(b, target, optimize, stage.markup_c)); + } const tests = b.addTest(.{ .root_module = test_app_mod, .use_llvm = useLlvmWorkaround(target) }); const test_step = b.step("test", "Run tests"); test_step.dependOn(&b.addRunArtifact(tests).step); @@ -2059,6 +2204,17 @@ fn appModule(b: *std.Build, dep: *std.Build.Dependency, target: std.Build.Resolv // toolchain's runtime needs libc. app_mod.link_libc = true; app_mod.addObjectFile(stage.archive); + // Debug reads src/app.native only through the separately linked C + // data object. Supplying this generated module in Debug would put + // the authored root back into the app-code dependency graph even + // though the runner's comptime branch never imports it. + if (optimize != .Debug) { + app_mod.addImport("app_markup_root", b.createModule(.{ + .root_source_file = stage.app_markup_root, + .target = target, + .optimize = optimize, + })); + } // The in-process service archive links beside it (distinct symbol // prefix; its runtime internals are localized). if (stage.service_archive) |service_archive| app_mod.addObjectFile(service_archive); diff --git a/docs/src/app/docs/building-components/page.mdx b/docs/src/app/docs/building-components/page.mdx index e6369b7ba..5f66cb360 100644 --- a/docs/src/app/docs/building-components/page.mdx +++ b/docs/src/app/docs/building-components/page.mdx @@ -1,6 +1,6 @@ # Building Components -The library's built-ins cover the common register, and [theming](/docs/theming) restyles all of them at once. This page is about the pieces the library does not hand you: how to build a component of your own — first as a markup template, then as a Zig view function when the shape needs one — how it themes, and how component files spread across an app. Component code is toolkit-extension territory, so the Zig here applies whatever language the app core is written in: a TypeScript app that needs one custom widget writes that widget in Zig and keeps its core in TypeScript. The mechanics (template grammar, import rules, slots) are specified in [Native UI](/docs/native-ui#templates); this page builds one real component end to end. +The library's built-ins cover the common register, and [theming](/docs/theming) restyles all of them at once. App components are Native markup templates: they compose built-ins, accept values through template args, accept content through slots, and live in `.native` component files. Component logic stays in the TypeScript core's ordinary `update` and derived helpers. The mechanics (template grammar, import rules, slots) are specified in [Native UI](/docs/native-ui#templates); this page builds one real component end to end. The ownership model in one line: **use and theme the built-ins by default; eject a library composite when you need to own its shape; build new composites from primitives when the library has no shape for it.** The last two are this page. @@ -91,37 +91,11 @@ Args carry values; a `` carries markup. A template body may mark one inse This is the container-component pattern: the template owns the frame, the caller owns the content. The full rules (one slot per body, children without a slot are an error, ids hash as if inlined) are in [Native UI § Components](/docs/native-ui#components). -## When a component needs Zig - -The markup grammar is deliberately closed, and a few shapes sit outside it — the honest list is in [Native UI § Elements](/docs/native-ui#elements): components that carry **image ids** (pixels registered at runtime; the avatar's `image="{binding}"` is the one declarative exception), **per-cell templates** (a data grid's arbitrary render-per-column callbacks), and **Zig-side floating surfaces** (`popover`, `menu_surface`; the anchored `dropdown-menu` covers the declarative case). Beyond those, anything needing per-state styling past tokens (`ElementOptions.style`) or logic past the expression language belongs in Zig. - -A Zig component is just a function that takes the view builder and returns a node — the same primitives markup lowers to, with the same structural identity rules: - -```zig -const canvas = native_sdk.canvas; -const Ui = canvas.Ui(Msg); - -/// A contact row with an avatar image. Image pixels are registered at -/// runtime and referenced by ImageId — a runtime value markup attributes -/// cannot carry — so this component is a Zig view function. -fn contactRow(ui: *Ui, name: []const u8, initials: []const u8, image: canvas.ImageId) Ui.Node { - return ui.el(.row, .{ - .gap = 10, - .padding = 8, - .cross = .center, - .style_tokens = .{ .background = .surface, .radius = .md }, - .semantics = .{ .role = .listitem, .label = name }, - }, .{ - // A zero id keeps the initials fallback — write the id into the - // model only on successful registration, and loading states cost - // no extra branch here. - ui.avatar(.{ .image = image, .size = .sm }, initials), - ui.text(.{ .grow = 1 }, name), - }); -} -``` +## Put component logic in the core + +Templates own structure, not state. Keep state transitions in `update`, expose reusable derived values as exported TypeScript helpers, and bind those values into the component. A component never runs an app callback while the view builds. -Call it from any Zig view (`contactRow(ui, contact.name, contact.initials, contact.avatar_image)`), key it inside `ui.each` loops like any node, and compose it with everything else the builder makes. Markup views and Zig views are not either/or per app — a markup root can be paired with Zig-built windows, and a Zig root can embed compiled markup fragments — but one component is one form: pick markup when the grammar covers it (hot reload and `native check` come free), Zig when it does not. +When the closed markup vocabulary does not expose a lower-level engine capability yet, there is no app-side escape element or required Zig file. Use the nearest built-in composition and track the missing grammar admission. Toolkit extensions remain an SDK implementation concern. ## Theming your component @@ -129,31 +103,18 @@ Your component themes the same way the built-ins do: through the token system, n In markup, the style attributes (`background`, `foreground`, `border-color`, `radius`, ...) are token **references** — the stat card above says `background="surface"`, not a hex value. References resolve against the app's live tokens on every rebuild, so the card follows dark mode, a theme-pack switch, and every override with zero component code. Unknown token names are check/compile errors. -In a Zig view, `ElementOptions.style_tokens` is the same channel — `.style_tokens = .{ .background = .surface, .radius = .md }` records the reference, and the app loop resolves it against the current `DesignTokens` when the tree finalizes (`finalizeWithTokens`), re-resolving on every retheme. Explicit values through `ElementOptions.style` always win over a token reference; use them only for the values that are genuinely not design tokens (a user-picked highlight color, a data-derived fill). - -State washes follow one rule: hover feedback belongs to acting controls. List rows, menu items, buttons, and tab triggers wash on hover because the fill is the affordance — it names the thing you are about to act on. An image-forward content tile is the opposite case — a cover-art grid, a photo card — where the pointer rests on content, not a control register, and a wash over the artwork reads as a smudge. Those surfaces go quiet with the quiet-surface knob, `.style = .{ .quiet_hover = true }`, which silences only the hover fill: the pressed wash still marks the moment of commitment, and the focus ring, cursor intent, and hit testing keep their own channels. Like everything per-state beyond tokens, it is a Zig-side style decision; markup stays in the token vocabulary. +State washes follow the built-in controls' behavior. Markup components customize their token references and compose the appropriate control kinds. A Zig view extension may use `ElementOptions.style` for a state-specific visual the token vocabulary does not expose; keep that exception local to the extension. -And the parts of your component that are built-in controls stay themed for free: the ` - + Todo - - - - - {c.title} - - NAT-{c.ticketNumber} - - - OA - - - CL - - - - - - + - - + + Doing - - - - - {c.title} - - NAT-{c.ticketNumber} - - - OA - - - CL - - - - - - + - - + + Done - - - - - {c.title} - - NAT-{c.ticketNumber} - - - OA - - - CL - - - - - - + - + diff --git a/examples/kanban/src/components/board-column.native b/examples/kanban/src/components/board-column.native new file mode 100644 index 000000000..c7aad2331 --- /dev/null +++ b/examples/kanban/src/components/board-column.native @@ -0,0 +1,26 @@ + + diff --git a/skill-data/native-ui/SKILL.md b/skill-data/native-ui/SKILL.md index a51d39e42..eb384db38 100644 --- a/skill-data/native-ui/SKILL.md +++ b/skill-data/native-ui/SKILL.md @@ -1082,7 +1082,7 @@ Rules and semantics: - SLOTS: a template body may contain one `` (attribute-less, childless; named slots do not exist). The `` site's children build IN THE CONSUMER'S SCOPE — they see the model paths and loop variables where the use is written — and land at the slot's position; ids hash as if inlined. A use with no children renders the slot empty; children on a slotless template are an error; a `` inside use-site children (forwarding) is an error. - IMPORTS: `` lines go at the very top of a file, before its templates. Paths are relative to the importing file (subdirectories and transitive imports fine, always under the root view file's directory — absolute paths and escapes are errors). An imported file defines templates ONLY (a component file; a view root inside one is an error, and a component file checks standalone). Importing splices the file's templates (transitively) BEFORE yours, in import order — as if pasted at the import site — so define-before-use stays the only ordering rule. Cycles are reported with the cycle path; duplicate template names are an error naming both definition sites. -Both engines implement templates, defaults, slots, and imports: the interpreter expands at build time (hot reload re-resolves imports from disk, so edits to imported files reload), and the compiled engine inlines at comptime with the identical result. A document with imports compiles through `canvas.CompiledMarkupImports(Model, Msg, "root.native", &sources)` where `sources` is a `canvas.ui_markup.SourceFile` set (`.{ .path = "components/cards.native", .source = @embedFile("components/cards.native") }`, paths relative to the root file's directory); pass the same set on `MarkupOptions.sources` for the runtime engine. See `examples/kanban/src/board.native` + `examples/kanban/src/components/board-column.native`. +Both engines implement templates, defaults, slots, and imports: the interpreter expands at build time (hot reload re-resolves imports from disk, so edits to imported files reload), and the compiled engine inlines at comptime with the identical result. A default TypeScript app's generated launcher discovers every component `.native` file under `src/`, embeds the same source set for both engines, and wires it on desktop and mobile. The main view can import anywhere under `src/`; secondary-window roots remain under `src/windows/`. Lower-level wiring compiles an imported document through `canvas.CompiledMarkupImports(Model, Msg, "root.native", &sources)` and passes the same `canvas.ui_markup.SourceFile` set on `MarkupOptions.sources`. See `examples/kanban/src/app.native` + `examples/kanban/src/components/board-column.native`. ## Code in markup: `` diff --git a/skill-data/ts-core/SKILL.md b/skill-data/ts-core/SKILL.md index 379352322..3ca59ba69 100644 --- a/skill-data/ts-core/SKILL.md +++ b/skill-data/ts-core/SKILL.md @@ -217,7 +217,7 @@ Commands are constructed inline in the return path and nowhere else (NS1017): ne Export `windows(model): readonly WindowDescriptor[]` to derive the secondary windows that should exist from committed model state. Import `WindowDescriptor` from `@native-sdk/core/events`, and construct entries with `windowDescriptor` from `@native-sdk/core` so omitted fields receive the canonical defaults. Presence is liveness: adding a descriptor creates the window, removing it closes the window and releases its retained view. -Each possible label has a statically compiled Native markup view at `src/windows/