chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:29:49 +08:00
commit 505bac6b53
1470 changed files with 457757 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"files.associations": { "*.native": "html" }
}
+32
View File
@@ -0,0 +1,32 @@
# notes
The daily-driver shape — folders sidebar, note list, editor — authored in markup + Zig. The whole view lives in `src/notes.native` (compiled at comptime, hot-reloaded in dev builds); `src/model.zig` is the logic: folders and notes as model-owned tables, everything showable derived per rebuild, and persistence as one store file through the effects channel. `src/main.zig` is the wiring — shell scene, the paper/evergreen theme, the store path, and the keyboard map.
```sh
native dev
```
## What it demonstrates
- **Derived state, never stored** — a note is just its body: the list title is the first non-empty line, the preview snippet is the collapsed text after it, and the "2m / 3h / 1w" timestamps derive from the model's `native_sdk.Clock` seam at view time (a repeating fx timer refreshes them; tests swap in a `TestClock`). Editing a note bumps its edit time, so the list re-sorts under your cursor — the daily-driver behavior, for free, because ordering is derived too.
- **Persistence through file effects with a debounced autosave** — the whole store (folders + notes, byte-counted bodies, binary-safe) serializes to one file in the per-app data directory (`native_sdk.app_dirs`). Edits re-arm a one-shot fx timer (`save_debounce_ms`), structural changes write immediately, and exactly one write is in flight at a time — a save requested mid-write re-persists on the acknowledgement. `init_fx` restores the store before the first paint; a hosts-without-timers rejection degrades to save-on-every-edit, never to silence.
- **Keyboard-first through app shortcuts** — every mutation the UI reaches is also a registered shortcut (declared in `app.zon`, delivered as command events through `on_command`): Cmd+N new note, Cmd+Shift+N new folder, Cmd+Shift+R rename, Cmd+Backspace delete (permanently, from inside Recently Deleted), Cmd+Shift+C copy, Cmd+Opt+Up/Down to walk the list, Cmd+1…7 folder jumps, and Escape to close the dialog, then clear the search (an open context menu consumes Escape itself). The idle editor pane renders the whole map.
- **Real context menus, declared in markup** — each folder and note row carries a `<context-menu>` of `<menu-item>`s: right/ctrl-click presents it through the platform (a real `NSMenu` on macOS; hosts without a native presenter mount the same items as an anchored surface automatically), and each item dispatches a typed Msg carrying the row's id (`trash_note:{n.id}`), so acting never requires selecting first. The model holds no open-menu state — presentation belongs to the platform. Folder rows declare Rename and Delete — the rail has no per-action buttons; note rows declare Copy and Delete; Recently Deleted rows declare Restore and Delete Permanently; the synthetic All Notes row declares an empty item set, so no menu presents. The whole flow is automation-drivable: snapshots list each row's items, `widget-context-press` performs the real right-click, and `widget-context-menu` invokes an item through the same dispatch a real pick takes.
- **Recently Deleted, present by availability** — deleting a note stamps `deleted_ms` instead of removing the record; the sidebar's Recently Deleted row (and scope) exists only while trashed notes do. Trashed notes open read-only with Restore as the pane's one action; Delete Permanently (the row menu, or Cmd+Backspace inside the scope) is the only path that removes a record. Deleting a folder moves its live notes here rather than losing them. The store format carries the deleted state (v2); v1 stores load cleanly with every note live.
- **The editor is the pane** — the open note renders as text on the pane's own field: the textarea's control chrome (fill, border, focus ring) is dissolved into the background tokens, one muted meta line ("Edited 2m ago · 67 words") sits above the text, and the split hairline is the only seam against the list.
- **Resizable panes with model-owned fractions** — the three-pane frame is two nested `<split>`s (sidebar seam, list/editor seam): dragging a divider (or focusing it and using Left/Right, Home/End) dispatches `on-resize` with the applied fraction, `update` stores it, and the rebuild lays the panes exactly there; `min-width` on each pane bounds the drag.
- **The folder rail is a disclosure tree** — a `<tree>` of `role="treeitem"` rows (flat today, every row a leaf): Up/Down move the folder selection through the rows — selection follows focus through each row's `on-press`, the same Msg a click dispatches — Home/End jump to the edges, and Enter/Space activate.
- **Flat house list rows** — folder and note rows are `<list-item>` composites: element children (icon + name + count, title + snippet) flow inside the row's own flat chrome, so hover and selection render as full-width washes with no per-row border or fill at rest, and the row itself is the one hit target carrying the press handler, the selection state, and the accessible `label`.
- **A modal dialog in markup** — folder create/rename is a `<dialog>` stacked over the app under an `<if>`, with a scrim panel that closes on click (painted with the theme's `shadow` token — nothing else in this app casts shadows, so the token doubles as the backdrop). Validation is inline: empty and duplicate names keep the dialog up with a hint.
- **Clipboard effects** — Copy (a note row's menu item, or Cmd+Shift+C for the open note) pipes the note body through `fx.writeClipboard` (the platform pasteboard seam), one typed Msg with an explicit outcome.
- **Search as a pure filter** — the search field scopes the visible rows (case-insensitive, full text, across folders); the `<for>`/`<else>` empty state explains itself differently for "no notes" and "no matches". Clearing is the field's own built-in trailing x (or Escape), arriving as an ordinary `.clear` edit through `on-input` — no external clear button.
- **System appearance, followed live** — the paper/evergreen palette derives per rebuild from the scheme `on_appearance` delivers, so flipping the OS between light and dark re-themes the window immediately; there is no in-window theme control by design.
- **Controlled scrolling** — the note list's scroll offset is model-owned: `on-scroll` stores the applied offset, the `value` binding echoes it back, so rebuilds (edits re-sorting the list, saves, timer ticks) never lose the list's place.
## Fixed capacities
Folders cap at 6 (`max_folders` — the create button disables, the shortcut path reports), notes at 48 x 4 KiB bodies (`max_notes`/`max_note_bytes` — every visible row mounts real widgets, so the cap also keeps the tree far under the 1024-node per-view layout budget; an over-cap paste is clamped with a status note), search at 48 bytes, and the serialized store at ~200 KiB (`max_store_bytes`, sized so a full store always fits under the effect channel's 1 MiB bound).
## Tests
`native test` (or root `zig build test-example-notes`) drives the real dispatch paths: title/snippet/relative-time derivation, store round-trips (including garbage, header-only, orphaned-note, and deleted-state inputs) plus the v1 → v2 store migration, edit → debounce timer → serialized write → pending-save re-persist through the fake effect executor, boot-time restore into the widget tree, the dialog flow through automation `widget-click` (create, duplicate-name hint, rename prefill, autofocus into the name field, scrim click-away, capacity), the row context menus through automation `widget-context-press` (asserting the platform-presented items) and `widget-context-menu` item invocation (copy/delete on notes, rename/delete on folders, restore/purge in Recently Deleted, presence-by-availability of the trash row, the item-less All Notes row), the read-only Recently Deleted editor with its Restore action, the whole shortcut map through platform shortcut events (including trash-scope permanent delete), live search filtering and the built-in clear affordance through raw pointer events, folder/note row clicks through the pressable list rows, compiled/interpreter markup parity across the dialog, the declared row menus, and the Recently Deleted scope, the system appearance re-deriving the tokens live through platform events, the controlled note-list scroll round-trip, and layout + a11y audit sweeps over the same states plus the anchored fallback menu surface presenter-less hosts show.
+57
View File
@@ -0,0 +1,57 @@
.{
.id = "dev.native_sdk.notes",
.name = "notes",
.display_name = "Notes",
.description = "A three-pane notes app with folders, search, and local storage.",
.version = "0.1.0",
.icons = .{"assets/icon.png"},
.platforms = .{"macos"},
.permissions = .{ "view", "command" },
.capabilities = .{ "native_views", "gpu_surfaces", "shortcuts" },
.shortcuts = .{
.{ .id = "notes.new-note", .key = "n", .modifiers = .{"primary"} },
.{ .id = "notes.new-folder", .key = "n", .modifiers = .{ "primary", "shift" } },
.{ .id = "notes.rename-folder", .key = "r", .modifiers = .{ "primary", "shift" } },
.{ .id = "notes.delete-note", .key = "backspace", .modifiers = .{"primary"} },
.{ .id = "notes.copy-note", .key = "c", .modifiers = .{ "primary", "shift" } },
.{ .id = "notes.prev-note", .key = "arrowup", .modifiers = .{ "primary", "option" } },
.{ .id = "notes.next-note", .key = "arrowdown", .modifiers = .{ "primary", "option" } },
.{ .id = "notes.dismiss", .key = "escape" },
.{ .id = "notes.folder-1", .key = "1", .modifiers = .{"primary"} },
.{ .id = "notes.folder-2", .key = "2", .modifiers = .{"primary"} },
.{ .id = "notes.folder-3", .key = "3", .modifiers = .{"primary"} },
.{ .id = "notes.folder-4", .key = "4", .modifiers = .{"primary"} },
.{ .id = "notes.folder-5", .key = "5", .modifiers = .{"primary"} },
.{ .id = "notes.folder-6", .key = "6", .modifiers = .{"primary"} },
.{ .id = "notes.folder-7", .key = "7", .modifiers = .{"primary"} },
},
.shell = .{
.windows = .{
.{
.label = "main",
.title = "Native SDK Notes",
.width = 1180,
.height = 760,
// The floor the layout audit sweep proves clean (three
// panes, list rows, editor toolbar): the window stops
// resizing here instead of clipping the panes below it.
.min_width = 760,
.min_height = 520,
.restore_state = false,
.restore_policy = "center_on_primary",
.titlebar = "hidden_inset_tall",
.views = .{
.{ .label = "notes-canvas", .kind = "gpu_surface", .fill = true, .role = "Notes canvas", .accessibility_label = "Notes", .gpu_backend = "metal", .gpu_pixel_format = "bgra8_unorm", .gpu_present_mode = "timer", .gpu_alpha_mode = "opaque", .gpu_color_space = "srgb", .gpu_vsync = true },
},
},
},
},
.security = .{
.navigation = .{
.allowed_origins = .{ "zero://app", "zero://inline" },
.external_links = .{ .action = "deny" },
},
},
.web_engine = "system",
.cef = .{ .dir = "third_party/cef/macos", .auto_install = false },
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+273
View File
@@ -0,0 +1,273 @@
//! notes: the daily-driver shape — folders sidebar, note list, editor —
//! authored in markup + Zig.
//!
//! The whole view lives in `src/notes.native` (compiled at comptime, hot
//! reloaded in dev); `src/model.zig` is the logic: folders and notes as
//! model-owned tables, titles/snippets/relative-times derived per rebuild,
//! and persistence as one store file through the effects channel with a
//! debounced autosave. This file is the app wiring — shell scene, the
//! paper/evergreen theme, the store path, and the keyboard map.
//!
//! Keyboard-first: every mutation the buttons reach is also a registered
//! app shortcut (declared in app.zon, delivered as command events through
//! `on_command`) — new note/folder, rename, delete, copy, next/prev note,
//! cmd+digit folder jumps, and Escape to dismiss.
const std = @import("std");
const builtin = @import("builtin");
const runner = @import("runner");
const native_sdk = @import("native_sdk");
pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic);
const canvas = native_sdk.canvas;
const geometry = native_sdk.geometry;
const app_dirs = native_sdk.app_dirs;
const model_mod = @import("model.zig");
pub const Model = model_mod.Model;
pub const Msg = model_mod.Msg;
pub const update = model_mod.update;
pub const canvas_label = "notes-canvas";
pub const window_width: f32 = 1180;
pub const window_height: f32 = 760;
/// Content min-size floor the window enforces: the smallest size where
/// the three panes, the note-list rows, and the editor toolbar all lay
/// out without clipping or wrapping over each other — proven by the
/// layout audit sweep in tests.zig, which sweeps from exactly this floor.
pub const window_min_width: f32 = 760;
pub const window_min_height: f32 = 520;
const app_permissions = [_][]const u8{ native_sdk.security.permission_command, native_sdk.security.permission_view };
const shell_views = [_]native_sdk.ShellView{
.{ .label = canvas_label, .kind = .gpu_surface, .fill = true, .role = "Notes canvas", .accessibility_label = "Notes", .gpu_backend = .metal, .gpu_pixel_format = .bgra8_unorm, .gpu_present_mode = .timer, .gpu_alpha_mode = .@"opaque", .gpu_color_space = .srgb, .gpu_vsync = true },
};
const shell_windows = [_]native_sdk.ShellWindow{.{
.label = "main",
.title = "Native SDK Notes",
.width = window_width,
.height = window_height,
.min_width = window_min_width,
.min_height = window_min_height,
.restore_state = false,
// Tall hidden-inset titlebar (declared in app.zon too, which threads
// it through the STARTUP window create): the header row IS the
// titlebar — it pads its leading edge past the traffic lights via
// `on_chrome` and is the window's drag surface (`window-drag` in
// notes.native).
.titlebar = .hidden_inset_tall,
.views = &shell_views,
}};
pub const shell_scene: native_sdk.ShellConfig = .{ .windows = &shell_windows };
// --------------------------------------------------------------- commands
// Shortcut command ids: registered in app.zon (`.shortcuts`), delivered
// as command events, mapped to Msgs here. One spelling, three homes:
// app.zon, this table, and the on-screen keyboard reference.
pub const cmd_new_note = "notes.new-note"; // primary+N
pub const cmd_new_folder = "notes.new-folder"; // primary+shift+N
pub const cmd_rename_folder = "notes.rename-folder"; // primary+shift+R
pub const cmd_delete_note = "notes.delete-note"; // primary+backspace
pub const cmd_copy_note = "notes.copy-note"; // primary+shift+C
pub const cmd_prev_note = "notes.prev-note"; // primary+option+arrowup
pub const cmd_next_note = "notes.next-note"; // primary+option+arrowdown
pub const cmd_dismiss = "notes.dismiss"; // escape
/// `notes.folder-1` … `notes.folder-7`: primary+digit jumps to the
/// sidebar position (1 = All Notes, 2… = folders in creation order).
pub const folder_command_prefix = "notes.folder-";
pub fn command(name: []const u8) ?Msg {
if (std.mem.eql(u8, name, cmd_new_note)) return .new_note;
if (std.mem.eql(u8, name, cmd_new_folder)) return .open_create_folder;
if (std.mem.eql(u8, name, cmd_rename_folder)) return .open_rename_folder;
if (std.mem.eql(u8, name, cmd_delete_note)) return .delete_note;
if (std.mem.eql(u8, name, cmd_copy_note)) return .copy_note;
if (std.mem.eql(u8, name, cmd_prev_note)) return .prev_note;
if (std.mem.eql(u8, name, cmd_next_note)) return .next_note;
if (std.mem.eql(u8, name, cmd_dismiss)) return .dismiss;
if (std.mem.startsWith(u8, name, folder_command_prefix)) {
const digit = std.fmt.parseInt(usize, name[folder_command_prefix.len..], 10) catch return null;
if (digit == 0) return null;
return .{ .select_folder_at = digit - 1 };
}
return null;
}
// -------------------------------------------------------------------- app
/// Debug builds keep the runtime markup engine for hot reload; release
/// builds compile it out entirely.
const dev_markup_reload = builtin.mode == .Debug;
const NotesApp = native_sdk.UiAppWithFeatures(Model, Msg, .{ .runtime_markup = dev_markup_reload });
pub const Effects = NotesApp.Effects;
/// TEA init: restore the persisted store before the first paint, and
/// start the repeating tick that keeps relative timestamps honest.
pub fn boot(model: *Model, fx: *Effects) void {
// Deterministic init under session replay: re-stamp the sample
// notes from the JOURNALED clock read. `main` seeded with the live
// clock before the runtime existed; this reseed lands before the
// first view build, so the ages the first frame renders come from a
// value the journal replays verbatim.
model_mod.seedAt(model, fx.wallMs());
if (model.store_path_len > 0) {
fx.readFile(.{
.key = model_mod.store_read_key,
.path = model.storePath(),
.on_result = Effects.fileMsg(.store_done),
});
}
fx.startTimer(.{
.key = model_mod.refresh_timer_key,
.interval_ms = model_mod.refresh_interval_ms,
.mode = .repeating,
.on_fire = Effects.timerMsg(.refresh_tick),
});
}
// ------------------------------------------------------------------- theme
/// A paper-and-evergreen palette derived per rebuild from the model's
/// scheme; the runtime stamps the surface scale afterwards. The
/// neutrals are the register's warm (stone) scale — anchors converted
/// from their published oklch values — so notes keeps its paper warmth
/// without tinted grays; the evergreen personality lives in the teal
/// accent alone (light oklch(0.511 0.096 186.391) = #00786f). The
/// dialog scrim is token-driven through the modal chrome (dim + blur),
/// so the shadow token is just a shadow again.
pub fn notesTokens(model: *const Model) canvas.DesignTokens {
const scheme = model.system_scheme;
var tokens = canvas.DesignTokens.theme(.{ .color_scheme = scheme });
tokens.colors = switch (scheme) {
.light => .{
.background = canvas.Color.rgb8(250, 250, 249),
.surface = canvas.Color.rgb8(255, 255, 255),
.surface_subtle = canvas.Color.rgb8(245, 245, 244),
.surface_pressed = canvas.Color.rgb8(231, 229, 228),
.text = canvas.Color.rgb8(12, 10, 9),
.text_muted = canvas.Color.rgb8(121, 113, 107),
.border = canvas.Color.rgb8(231, 229, 228),
.accent = canvas.Color.rgb8(0, 120, 111),
.accent_text = canvas.Color.rgb8(240, 253, 250),
.destructive = canvas.Color.rgb8(231, 0, 11),
.destructive_text = canvas.Color.rgb8(250, 250, 250),
.success = canvas.Color.rgb8(22, 163, 74),
.success_text = canvas.Color.rgb8(250, 250, 250),
.warning = canvas.Color.rgb8(217, 119, 6),
.warning_text = canvas.Color.rgb8(250, 250, 250),
.focus_ring = canvas.Color.rgb8(166, 160, 155),
.shadow = canvas.Color.rgba8(0, 0, 0, 26),
.disabled = canvas.Color.rgb8(245, 245, 244),
},
.dark => .{
.background = canvas.Color.rgb8(12, 10, 9),
.surface = canvas.Color.rgb8(28, 25, 23),
.surface_subtle = canvas.Color.rgb8(41, 37, 36),
.surface_pressed = canvas.Color.rgba8(255, 255, 255, 38),
.text = canvas.Color.rgb8(250, 250, 249),
.text_muted = canvas.Color.rgb8(166, 160, 155),
.border = canvas.Color.rgba8(255, 255, 255, 26),
.accent = canvas.Color.rgb8(0, 187, 167),
.accent_text = canvas.Color.rgb8(12, 10, 9),
.destructive = canvas.Color.rgb8(255, 100, 103),
.destructive_text = canvas.Color.rgb8(250, 250, 250),
.success = canvas.Color.rgb8(34, 197, 94),
.success_text = canvas.Color.rgb8(9, 9, 11),
.warning = canvas.Color.rgb8(245, 158, 11),
.warning_text = canvas.Color.rgb8(9, 9, 11),
.info = canvas.Color.rgb8(167, 139, 250),
.info_text = canvas.Color.rgb8(9, 9, 11),
.focus_ring = canvas.Color.rgb8(121, 113, 107),
.shadow = canvas.Color.rgba8(0, 0, 0, 150),
.disabled = canvas.Color.rgb8(41, 37, 36),
},
};
tokens.radius = .{ .sm = 6, .md = 8, .lg = 11, .xl = 14 };
return tokens;
}
/// System appearance flows into the model and the tokens re-derive from
/// it — the app follows the OS scheme live, with no in-window theme UI.
/// Chrome overlay geometry flows into the model (tall hidden-inset
/// titlebar): delivered before the first view build and again when it
/// changes — entering fullscreen hides the traffic lights and this goes
/// to zero.
pub fn onChrome(chrome: native_sdk.WindowChrome) ?Msg {
return .{ .chrome_changed = chrome };
}
pub fn onAppearance(appearance: native_sdk.Appearance) ?Msg {
return .{ .system_scheme = switch (appearance.color_scheme) {
.light => .light,
.dark => .dark,
} };
}
// -------------------------------------------------------------------- view
pub const NotesUi = canvas.Ui(Msg);
pub const notes_markup = @embedFile("notes.native");
/// The comptime-compiled engine: same tree, ids, and handlers as the
/// interpreter, no parser in the binary.
pub const CompiledNotesView = canvas.CompiledMarkupView(Model, Msg, notes_markup);
// -------------------------------------------------------------------- main
pub fn main(init: std.process.Init) !void {
const app_state = try std.heap.page_allocator.create(NotesApp);
defer std.heap.page_allocator.destroy(app_state);
var model = model_mod.initialModel(.system);
// Resolve where the store persists: the per-app data directory
// (~/Library/Application Support/notes on macOS). Failure just
// disables persistence — never a startup error.
var dir_buffer: [model_mod.max_path_bytes]u8 = undefined;
var file_buffer: [model_mod.max_path_bytes]u8 = undefined;
const env = native_sdk.debug.envFromMap(init.environ_map);
const platform_value = app_dirs.currentPlatform();
if (app_dirs.resolveOne(.{ .name = "notes" }, platform_value, env, .data, &dir_buffer)) |data_dir| {
if (app_dirs.join(platform_value, &file_buffer, &.{ data_dir, "store.txt" })) |store_path| {
model.setStorePath(store_path);
} else |_| {}
} else |_| {}
app_state.* = NotesApp.init(std.heap.page_allocator, model, .{
.name = "notes",
.scene = shell_scene,
.canvas_label = canvas_label,
.update_fx = update,
.init_fx = boot,
.tokens_fn = notesTokens,
.on_appearance = onAppearance,
.on_chrome = onChrome,
.on_command = command,
.view = CompiledNotesView.build,
.markup = if (dev_markup_reload)
.{ .source = notes_markup, .watch_path = "src/notes.native", .io = init.io }
else
null,
});
defer app_state.deinit();
try runner.runWithOptions(app_state.app(), .{
.app_name = "notes",
.window_title = "Native SDK Notes",
.bundle_id = "dev.native_sdk.notes",
.icon_path = "assets/icon.png",
.default_frame = geometry.RectF.init(0, 0, window_width, window_height),
.restore_state = false,
.js_window_api = false,
.security = .{
.permissions = &app_permissions,
.navigation = .{ .allowed_origins = &.{ "zero://inline", "zero://app" } },
},
}, init);
}
test {
_ = @import("tests.zig");
}
File diff suppressed because it is too large Load Diff
+215
View File
@@ -0,0 +1,215 @@
<!-- The notes daily driver: header with search, folders sidebar, note
list, editor, and the folder dialog as a stack overlay. Embedded into
the binary and hot-reloaded in dev: edit this file while the app runs
and the window updates without losing notes. -->
<stack>
<column background="background">
<!-- The header IS the titlebar (tall hidden-inset chrome): it is the
window's drag surface, leads with a spacer sized to the traffic
lights via on_chrome, and matches its height to the titlebar
band so its controls and the lights share a centerline. -->
<!-- Band contents: only working controls — search leads after the
window controls, the one primary action holds the trailing
corner, and the stretch between them stays bare drag surface. -->
<row height="{header_height}" padding="12" gap="10" cross="center" background="surface" window-drag="true" label="Notes header">
<spacer width="{chrome_leading}" />
<!-- Clearing is the search field's own affordance: the built-in
trailing x (and Escape) clear through the standard edit path. -->
<search-field text="{search}" placeholder="Search all notes…" width="300" on-input="search_edit" label="Search notes" />
<spacer grow="1" />
<button variant="primary" icon="plus" on-press="new_note">New note</button>
</row>
<separator />
<!-- Nested splitters: the outer seam resizes the folder sidebar, the
inner one the note list vs the editor. Fractions are MODEL-OWNED:
the runtime applies the drag, dispatches on-resize with the new
fraction, and the model echoes it back through value. -->
<split grow="1" value="{sidebar_split}" on-resize="sidebar_resized">
<column min-width="150" gap="8" padding="10" background="surface">
<text size="sm" foreground="text_muted">Folders</text>
<!-- The folder rail is a disclosure tree (flat today — every row a
leaf): Up/Down move the selection through the rows, Home/End
jump to the edges, Enter/Space select, and selection follows
focus through each row's on-press. -->
<!-- Flat house rows: list-item children flow inside the row's
own wash chrome (hover/selected are full-width washes, no
per-row border or fill at rest), and the rows sit flush —
the washes are the only separation. -->
<!-- Row actions live in a context menu, not buttons: each row
declares its items once and right/ctrl-click presents them
through the platform — the OS menu where the host has one,
the anchored fallback surface elsewhere. The model holds no
open-menu state at all. All Notes cannot be renamed or
deleted, so its item set is empty and no menu presents. -->
<tree label="Folders">
<for each="folderRows" key="id" as="f">
<list-item on-press="select_folder:{f.id}" selected="{f.selected}" padding="8" gap="8" cross="center" role="treeitem" label="{f.label}">
<icon name="folder" width="14" height="14" foreground="text_muted" />
<!-- One-line folder row: elide a long name at the rail's
width, never wrap onto the row below. -->
<text grow="1" wrap="false">{f.name}</text>
<badge variant="secondary">{f.count}</badge>
<context-menu>
<if test="{f.mutable}">
<menu-item on-press="rename_folder:{f.id}">Rename</menu-item>
<menu-item on-press="delete_folder:{f.id}">Delete</menu-item>
</if>
</context-menu>
</list-item>
</for>
<!-- Recently Deleted exists only while deleted notes do —
presence by availability, never a dead row. -->
<if test="{trashAvailable}">
<list-item on-press="select_trash" selected="{trashSelected}" padding="8" gap="8" cross="center" role="treeitem" label="Recently Deleted folder">
<icon name="trash" width="14" height="14" foreground="text_muted" />
<text grow="1" wrap="false">Recently Deleted</text>
<badge variant="secondary">{trashCount}</badge>
</list-item>
</if>
</tree>
<spacer grow="1" />
<button size="sm" variant="outline" on-press="open_create_folder" disabled="{foldersFull}" label="New folder">New Folder</button>
</column>
<split value="{list_split}" on-resize="list_resized">
<column min-width="220" gap="8" padding="10">
<row gap="8" cross="center">
<text size="sm" foreground="text_muted" grow="1">{listTitle}</text>
<badge variant="secondary">{noteCount}</badge>
</row>
<!-- Controlled scroll: the model owns the offset — on-scroll
stores the applied value, the binding echoes it back, so
the list keeps its place across every rebuild. -->
<scroll grow="1" value="{note_list_scroll}" on-scroll="note_list_scrolled" label="Note list">
<!-- Flat house rows, flush like the folder rail: each note is
a list-item whose selected/hover washes span the full
pane width. -->
<column>
<for each="noteRows" key="id" as="n">
<list-item on-press="open_note:{n.id}" selected="{n.active}" padding="10" label="{n.title}">
<column gap="4" grow="1">
<!-- Title leads, the age trails the same line; the
snippet gets the full second line. Titles elide
at the pane width, never wrap over the snippet. -->
<row gap="8" cross="center">
<text grow="1" wrap="false">{n.title}</text>
<text size="sm" foreground="text_muted">{n.time}</text>
</row>
<!-- One-line snippet by design: elide, never wrap
into the row below. -->
<text size="sm" foreground="text_muted" wrap="false">{n.snippet}</text>
</column>
<!-- One declared menu; the scope swaps its items — a
Recently Deleted row offers Restore instead of
Copy, and permanent delete instead of trash. -->
<context-menu>
<if test="{n.deleted}">
<menu-item on-press="restore_note:{n.id}">Restore</menu-item>
<menu-item on-press="purge_note:{n.id}">Delete Permanently</menu-item>
</if>
<else>
<menu-item on-press="copy_note_id:{n.id}">Copy</menu-item>
<menu-item on-press="trash_note:{n.id}">Delete</menu-item>
</else>
</context-menu>
</list-item>
</for>
<else>
<panel padding="14" label="Empty note list">
<column gap="4">
<text>{emptyTitle}</text>
<text size="sm" foreground="text_muted">{emptyHint}</text>
</column>
</panel>
</else>
</column>
</scroll>
</column>
<!-- The editor IS the pane: the textarea's control chrome (fill,
border, focus ring) dissolves into the pane's background, so
the note text sits directly on the same field as the list —
the split hairline is the only seam. The only other element
is one quiet meta line; actions live in the note's context
menu and the keyboard map, not in a button row above the
text. -->
<column min-width="280">
<if test="{hasActiveNote}">
<if test="{activeNoteLive}">
<!-- The meta line's inset matches the textarea's own text
inset below it (pane padding + the control's text
inset), so the metadata and the note text share one
left edge. -->
<column grow="1" padding="10" gap="2">
<row padding="10" cross="center">
<text size="sm" foreground="text_muted" grow="1" wrap="false">{editorMeta}</text>
</row>
<!-- Focus shows as the caret, as editors do — a focus
ring around the whole pane would read as chrome. -->
<textarea text="{editorText}" on-input="edit" grow="1" background="background" border-color="background" focus-ring="background" placeholder="Start writing — the first line becomes the title" label="Note editor" />
</column>
</if>
<else>
<!-- A Recently Deleted note reads, never edits: restore is
the pane's one action, permanent delete stays in the
row's menu (and Cmd+Backspace). -->
<column grow="1" padding="10" gap="2">
<row padding="10" gap="10" cross="center">
<text size="sm" foreground="text_muted" grow="1" wrap="false">{editorMeta}</text>
<button size="sm" variant="outline" on-press="restore_note:{active_note}" label="Restore note">Restore</button>
</row>
<scroll grow="1" label="Deleted note">
<column padding="10">
<text wrap="true">{editorText}</text>
</column>
</scroll>
</column>
</else>
</if>
<else>
<column grow="1" padding="24" main="center" cross="center" gap="10">
<text>No note selected</text>
<text size="sm" foreground="text_muted">Create one with Cmd+N, or pick a note from the list.</text>
<spacer height="8" />
<column gap="3" width="320">
<for each="shortcut_hints" as="s">
<row gap="10">
<text size="sm" foreground="text_muted" width="130">{s.keys}</text>
<text size="sm" grow="1">{s.action}</text>
</row>
</for>
</column>
</column>
</else>
</column>
</split>
</split>
<status-bar>{statusLine}</status-bar>
</column>
<if test="{dialogOpen}">
<!-- The dialog's modal chrome paints the real scrim (token-driven
dim + backdrop blur across the window); this panel is the
outside-click dismiss catcher, wearing the same scrim wash so
the pair reads as one overlay. -->
<panel background="scrim" on-press="close_dialog" label="Dialog backdrop">
<column main="center" cross="center">
<dialog width="380" height="190" label="{dialogTitle}">
<column padding="18" gap="10">
<text>{dialogTitle}</text>
<!-- The field mounts with the dialog, so autofocus lands the
keyboard in it the moment the dialog opens — type, Enter,
done, no click in between. -->
<text-field text="{folderName}" placeholder="Folder name" autofocus="true" on-input="folder_field_edit" on-submit="confirm_dialog" label="Folder name" />
<if test="{dialog_hint}">
<text size="sm" foreground="destructive">{dialog_hint}</text>
</if>
<spacer grow="1" />
<row gap="8" cross="center">
<text size="sm" foreground="text_muted" grow="1">Enter confirms · Esc cancels</text>
<button size="sm" variant="ghost" on-press="close_dialog" label="Cancel dialog">Cancel</button>
<button size="sm" variant="primary" on-press="confirm_dialog" disabled="{dialogNameEmpty}" label="Confirm dialog">{dialogConfirmLabel}</button>
</row>
</column>
</dialog>
</column>
</panel>
</if>
</stack>
File diff suppressed because it is too large Load Diff