Commit graph

1096 commits

Author SHA1 Message Date
Kayshen-X c001bdb41d test(shell-core): close multi-select capability test gap
Codex CONCERN on the previous fix: the test called
`SectionCapabilities::for_multi()` directly, so it would still
pass if `paint` regressed to call `for_kind` and bypassed the
multi-select carve-out entirely.

Closed the gap by introducing `PropertyPanel::capabilities()`
as the single source of truth that `paint` routes through:
- `is_multi` → `for_multi()`
- single → `for_kind(snapshot.kind_variant)`

`paint` now calls `self.capabilities()` rather than picking the
mask inline. The test asserts `panel.capabilities()` directly,
which will fail if `paint` or `capabilities` regress to bypass
either branch.

Also added a single-select cross-check (Rect selection) that
asserts the fallback path still exposes fill+stroke — protects
against the inverse regression (`for_kind` accidentally always
returns `for_multi`).

`SectionCapabilities` + its fields promoted from private to
`pub(crate)` so the test can read them via the accessor.

134 tests pass; cargo fmt + boundary check clean.
2026-05-12 03:25:45 +08:00
Kayshen-X 3b7f6ae95d fix(shell-core): multi-select panel actually paints union W/H
Codex caught a real bug in the multi-select aggregate panel:
I'd picked `NodeKind::Group` to hide fill+stroke, but Group
also has `size_options = false`, which hides the entire Size
section. So the W/H I bragged about in the commit message
were never being rendered.

Decoupled paint capability from the snapshot's `kind_variant`:
- New `SectionCapabilities::for_multi()` mask: keeps Size +
  Position + Layer + Effects + Export; hides flex + fill +
  stroke. Exactly the right mask for the v1 aggregate view.
- `paint` branches on `self.is_multi` to pick `for_multi` over
  `for_kind`. Hit-test paths already short-circuit on is_multi
  before reaching the capability lookup, so they don't need
  the branch.
- Reverted the snapshot's `kind_variant` back to `Frame` —
  the field is now informational only (paint capability is
  driven independently).
- Replaced `multi_select_panel_hides_fill_and_stroke_sections`
  test with `multi_select_caps_keep_size_hide_fill_and_stroke`
  that asserts the corrected mask: size_options=true,
  fill=false, stroke=false, flex_layout=false.

134 tests still pass; cargo fmt + boundary check clean; zero
files over 800 lines.
2026-05-12 03:04:26 +08:00
Kayshen-X b2c3dd1671 docs(shell-core): correct selected_node anchor doc
Stale comment claimed parity with TS `selectedIds[0]`, but the
Rust port anchors on the LAST entry of `selected_set` (most-
recently-added / most-recently-surviving id — see
`toggle_selection`). Rewrote the doc to describe the actual
Rust invariant and call out the TS divergence.
2026-05-12 02:58:57 +08:00
Kayshen-X c62709584d docs(shell-core): refresh property_panel_visible doc-comment
Stale comment said "multi-select hides pending an aggregated-
properties UI" — the aggregated UI landed in 8d51b110 and was
hardened in 38ad5a47, so the comment now contradicts the
implementation. Updated to describe the actual unified gate.
2026-05-12 02:54:50 +08:00
Kayshen-X 59c0c2b667 fix(shell-core): multi-select panel shows for 0x0 selections
Codex re-review pushed back on the previous "Q1 stays as-is"
rationale: single-select shows the panel for a 0x0 node (only
gates on `selected_node().is_some()`), so a 2+ 0x0 selection
should too. The previous implementation hid the panel by gating
on `selection_bounds().is_some()`, which filters zero-size
rects and thus returned None for all-zero multi-selections.

Aligning behavior:
- `Document::property_panel_visible()` now gates on "at least
  one selected id resolves on the active page" — same shape
  for single and multi, independent of bounds.
- `NodeSnapshot::from_multi_selection` falls back to
  `Rect::ZERO` when `selection_bounds()` returns None (so the
  snapshot still paints with 0 W/H).
- Added test `multi_select_panel_shows_even_when_all_zero_size`
  that selects two zero-bounds leaves and verifies the panel
  paints with W=H=0.

134 tests pass; cargo fmt + boundary check clean; mutators.rs
back under 800 lines via the inlined match-arm.
2026-05-12 02:49:38 +08:00
Kayshen-X 98c463e6db fix(shell-core): multi-select snapshot hides fill/stroke sections
Codex CONCERN on the multi-select panel feature: the v1 snapshot
used `kind_variant = Frame` so the fill + stroke sections would
still paint default placeholder values even though
`snapshot.fill / snapshot.stroke` are intentionally None for the
aggregate view. Users would see a misleading "current fill is
white" / "current stroke is black 1px" on a multi-selection
that has neither.

Switched the aggregate snapshot to `kind_variant = Group`, which
matches an existing `SectionCapabilities::for_kind` entry that
already disables flex_layout / size_options / fill / stroke and
leaves Position / Layer / Effects / Export visible — exactly the
right mask for an "untyped multi-selection" informational view.

Added `multi_select_panel_hides_fill_and_stroke_sections` test
asserting `caps.fill == false && caps.stroke == false` on the
aggregate snapshot. 133 tests pass (was 132).
2026-05-12 02:38:37 +08:00
Kayshen-X fb947295cd feat(shell): multi-select property panel — union bounds aggregate
When 2+ nodes are selected, the right rail used to disappear
entirely. Now it paints an aggregate view:

- `Document::selection_bounds()` returns the union of every
  selected node's `aggregate_bounds` on the active page (None
  when nothing resolves). Backed by a new
  `union_aggregate_bounds` walker helper.
- `Document::property_panel_visible()` extended to return true
  for `selection_count >= 2 && selection_bounds().is_some()`,
  unblocking the canvas-region rail reservation.
- `NodeSnapshot::from_multi_selection(doc)` builds the
  aggregate snapshot: x/y/w/h from the union rect, header
  shows "N items" / "N selected", `kind_variant: Frame` so
  per-kind section filtering hides text/line-specific rows.
- `PropertyPanel.is_multi` flag drives the inert-input mode:
  `hit_test` + `hit_test_action` short-circuit to None,
  `focus` is forced to None, draft is empty. v1 is read-only;
  broadcast editing across the set lands in a follow-up.

Snapshot construction refactored through a private
`build_from_snapshot` helper so single + multi paths share
the field-init boilerplate.

Tests (3 new, 132 total in shell-core):
- `multi_selection_panel_shows_union_bounds_and_is_inert` —
  selects two sample-doc nodes, asserts is_multi, the union
  bounds match, and both hit-tests return None.
- `property_panel_visible_handles_multi` — covers the three
  states (empty / single / multi) on the new visibility gate.
- Existing `property_panel_visible_only_for_single_selection`
  test inverted to `..._for_single_and_multi_selection` since
  multi-select is now a visible state.

cargo test --workspace + cargo fmt + boundary check all clean;
zero files over the 800-line ceiling.
2026-05-12 02:32:22 +08:00
Kayshen-X b11f064e79 fix(shell): defensive commit_layer_drag source-validity guard
Final codex re-review CONCERN: `commit_layer_drag` was relying on
`Document::reorder_before/after`'s own source-existence check to
no-op on a deleted source. Per codex: the commit path should bail
explicitly for symmetry with the existing cursor_move + paint
guards. Added the same `active_page().find(source).is_none()`
check on both native (`widget_host/input.rs`) and web
(`widget_host.rs`) at the top of `commit_layer_drag`, right after
the `!d.active` early-out.

No behavior change in the safe path (reorder still happens), but
short-circuits the drop_target_at + dispatch when the source is
gone.
2026-05-12 02:21:59 +08:00
Kayshen-X 33ee2f37ed refactor(shell): split web widget_host + harden layer-drag lifecycle
Three codex broad-review findings:

BLOCK — `openpencil-shell-web/src/widget_host.rs` was 886 lines
(over the 800 cap). Extracted `apply_press` + `apply_click`
(~301 lines combined) into a new sibling
`crates/openpencil-shell-web/src/widget_host/press.rs`,
mirroring the native split pattern. Spine drops to 590 lines.
Explicit `use super::{ChatDragState, DragState, LayerDragState,
MarqueeDragState, rect_contains, WidgetHost}` so the type
references inside the moved methods stay readable (Rust resolves
them via descendant module privacy, but the imports document
the dependency).

CONCERN — Stale `layer_drag` could outlive its dragged node if
the document mutated mid-drag (delete / cut / page switch).
Added a source-validity guard at three sites per host (native
+ web parity):
- `apply_cursor_move`: clears `layer_drag` if
  `active_page().find(d.source)` returns None.
- Both paint passes: suppress the drop-indicator when the
  source is no longer in the active page.
- `commit_layer_drag` already silently no-ops on missing
  source via the existing `reorder_before/after` guards.

CONCERN — Missing host-level end-to-end test for the drag-to-
reorder gesture. Added two tests in
`openpencil-shell-native/src/widget_host/input_tests.rs`:
- `layer_drag_to_reorder_commits_on_release_with_threshold_move`
  — full press → 4-px-threshold move → release; asserts the
  tree was reordered and `layer_drag` is cleared.
- `layer_drag_below_activation_threshold_is_a_click_not_a_reorder`
  — sub-threshold move; asserts click semantics (selection
  set, tree unchanged).

Verification:
- cargo test --workspace: all green (184+ tests).
- cargo fmt --all --check: clean.
- bash tools/check-widget-boundary.sh: PASS.
- find / awk file-size scan: zero files over 800 lines.
2026-05-12 02:18:01 +08:00
Kayshen-X 44a2895bb0 fix(shell-core): drag-to-reorder drop-at-end + threshold doc
Two codex stop-gate CONCERNs from the drag-to-reorder commit:

- `LayerPanel::drop_target_at` now returns `After(last_layer)` for
  cursor positions in the empty area below all rows (still inside
  the panel rect). Was returning None, creating a dead zone where
  the user couldn't drop a row at the very end of the list. New
  test `drop_target_at_in_empty_area_below_rows_drops_at_end`.
- Activation threshold for layer-drag is intentionally vertical-
  only (4 px). Added an explanatory comment on both native and
  web hosts so the asymmetry vs the marquee's 2D threshold is
  legible at the call site (the row-stack reorder axis is y;
  horizontal wiggle would steal click-feel from selection +
  eye/lock/chevron toggles on the same row).

Doc drift on `drop_target_at` reverted likewise — the comment now
documents all four return cases (over-row Before / After, below-
rows After-last, outside / above-rows None).

130 shell-core tests pass; cargo fmt + boundary check clean.
2026-05-11 22:14:38 +08:00
Kayshen-X 8457f4d629 feat(shell): LayerPanel drag-to-reorder — cross-parent reparenting
Drag a layer row in the LayerPanel; a 2 px primary-tint drop
indicator paints between rows; release commits the move. Supports
both same-parent reordering and cross-parent reparenting.

Core (`document/`):
- `Document::reorder_before(source, anchor)` / `reorder_after`
  with editability + cycle + existence pre-checks. The mutation
  phase calls `extract_node` + `insert_before/after_in_children`
  via shared walkers — guarded by the pre-check pass so the
  Err(node) arm of the insert helpers stays unreachable.
- New walkers: `extract_node`, `insert_before/after_in_children`
  (`Result<(), Node>` so the node bounces back on miss),
  `children_contain_descendant`. All `pub(in crate::document)`
  so sibling submodules (`mutators`) can call them via
  `super::walkers::*`.

Widget (`widgets/layer_panel.rs`):
- `DropPosition::{Before, After}` + `DropTarget { anchor,
  position, indicator_y }` re-exported from `widgets::`.
- `LayerPanel::drop_target_at(rect, point) -> Option<DropTarget>`
  walks the layer rows; upper half → Before, lower half → After.
- `LayerPanel.drop_target` field + `from_document_with_drop`
  ctor so the host can thread the active drop target through
  to paint without touching the existing API. Drop-indicator
  paints last so it sits above row chrome.
- Layout constants promoted to `pub(crate)` and tests moved to
  a sibling `widgets/layer_panel_tests.rs` to honor the 800-line
  cap (mirrors the `document/` split pattern).

Host wiring (native + web parity):
- `LayerDragState { source, start_y, current_x/y, active }` on
  both `WidgetHostNative` and web `WidgetHost`.
- `apply_press` peeks for a Layer row hit and seeds the drag
  candidate (selection still happens immediately on press).
- `apply_cursor_move` promotes candidate → active once the
  cursor moves past a 4 px screen-space threshold (mirrors the
  marquee activation heuristic).
- `apply_release_with_viewport` calls `commit_layer_drag`,
  which runs `drop_target_at` against the live cursor pos and
  dispatches to `reorder_before` / `reorder_after`. Viewport-
  less `apply_release` drops the candidate silently (no rect
  to compute against).
- Paint paths build the panel via `from_document_with_drop`
  when `layer_drag.active` is true.

Tests (8 new, 137 total in shell-core):
- 6 mutator tests in `document/tests_mutators.rs`:
  same-parent before/after move, cross-parent reparenting,
  cycle rejection, locked/hidden source rejection,
  same-id/missing-id rejection.
- 1 drop-target hit-test in `widgets/layer_panel_tests.rs`
  covering both halves of a row (Before / After) and the
  `indicator_y` contract.

All checks clean: cargo fmt + cargo test + cargo check
--workspace + tools/check-widget-boundary.sh. Every file
under the 800-line ceiling.
2026-05-11 22:06:02 +08:00
Kayshen-X 4784b267f9 docs(shell): note document.rs directory split in crates/CLAUDE.md
The 800-line cap section in `crates/CLAUDE.md` listed `widget_host`
and `property_panel` as the two split files; `document.rs` joined
them on 2026-05-11 (commit 512ed9b7). Updated the file-cap bullet
and the Document model heading to point at the directory.
2026-05-11 21:42:44 +08:00
Kayshen-X 927412ca39 refactor(shell-core): split document.rs into directory module
Pure refactor — no behavior change, no public API change. Closes the
persistent codex follow-up that document.rs (3455 lines) was the
last file violating the 800-line ceiling.

Split topology mirrors the existing widget_host pattern (one spine +
sibling submodules under the same name):

  document.rs              763  types + inherent impls + mod decls
  document/mutators.rs     737  impl Document { ... }
  document/walkers.rs      616  free tree-walk helpers + ReorderDirection
  document/chat.rs         105  ChatState / ChatAnchor / ChatRole /
                                ChatMessage + impls
  document/tests_mutators.rs    649  first half of test module
  document/tests_geometry.rs    661  second half of test module

Visibility:
- Walker fns promoted from crate-private `fn` to
  `pub(in crate::document) fn` so the sibling `mutators` submodule
  can `use super::walkers::*;` them. Restricted to `document` and
  its descendants; not exposed to other crates.
- `pub use walkers::ReorderDirection;` and
  `pub use chat::{ChatAnchor, ChatMessage, ChatRole, ChatState};`
  keep external paths (`crate::document::ReorderDirection`, etc.)
  stable for shell-native + shell-web.

Codex re-review: OK on all four checks (visibility leaks, public
API stability, mutator path correctness, test scope).

122 + 6 + 4 + 21 tests pass — no tests added, dropped, or modified;
the split is content-preserving. cargo check + fmt + widget
boundary check all clean.
2026-05-11 21:40:57 +08:00
Kayshen-X 2cda18318d feat(shell): selection handles + drag-create + per-node flags + LayerPanel polish
Re-apply 4 reset commits (1854dfa6 → b94274c6) bundled with session
follow-ons. Native + web hosts share the new behavior end-to-end.

Selection + canvas interaction:
- bounded Frame drag now translates descendants too
- 8 selection handles with hover-cursor feedback
- thinner selection outline + smaller AA handles
- handle-drag resize for rect/ellipse/polygon/line/frame/text
- drag-to-create shapes / frames / text from the active tool
- per-NodeKind hit-test (oval / triangle / line slack / point-in-poly)
- rotation pivot is kind-aware (handles negative-size Lines)

Per-node flags (TS parity):
- Node.hidden / locked / collapsed / fill_type (moved off Document.ui)
- mutators gated by is_editable / is_subtree_editable so locked /
  hidden subtrees can't be translated, resized, rotated, recolored,
  or deleted as collateral

Multi-select + marquee + clipboard + keyboard shortcuts:
- selected_set + anchor; shift+click toggles set membership
- marquee rect-select with screen-px threshold + ADD-only shift
- copy / cut / paste / duplicate / nudge / reorder / select-all
- escape one-layer-per-press priority cascade (property-focus →
  locale picker → shape picker → fill-type picker → chat → selection)
- Cmd-letter chord guards (!shift) so Cmd-Shift-letter doesn't fall
  through to text input; !modifier guards on named keys

LayerPanel polish:
- hover-reveal eye/lock affordances (TS parity)
- Eye → EyeOff icon when hidden; Lock → LockOpen when unlocked
- locked Lock renders in warm orange
- chevron expand/collapse for container rows; collapsed subtree
  hides from tree (paint/hit-test unaffected)
- `+` add-page button wired end-to-end (mints fresh id past
  max_node_id + 1, names "Page N", overflow-safe)
- smaller, refined trailing icons (12 px @ 1.2 stroke)
- 18 px chevron-to-kind-icon gap

RenderBackend trait grew fill_oval / stroke_oval / fill_polygon /
stroke_polygon / rotate so both native and web backends can paint
the new node shapes.

Refactor:
- split native widget_host.rs (1799 lines) into spine + 7 sibling
  submodules under widget_host/ to stay under the 800-line ceiling
- split web widget_host.rs into spine + paint + keyboard siblings
- amend tools/check-widget-boundary.sh + spec § 1.4 to allow
  widget_host/* sibling files; tighten `// glue:` marker rule to
  the immediately-preceding line (rustfmt-stable)

Stop-hook iterations addressed:
- allocator overflow guards (checked_add) on duplicate / paste /
  add_page paths
- subtree-size precheck before any id mint in deep_clone
- hidden subtree skipped in paint AND selection overlay
- nested protected delete leak closed via is_subtree_editable
- per-FocusKind hex/numeric input gating; sticky `#` prefix on hex
- ScaleFactorChanged refreshes viewport from window.inner_size()

122 shell-core tests pass; cargo fmt --all --check clean;
cargo check --workspace clean; widget boundary check clean.
2026-05-11 21:30:06 +08:00
Fini 5755c8cd88 fix(pen-core): preserve cornerRadius on media-clipping frames
Codex stop-hook on the prior strip-nested-card-decoration commit
caught a regression: cornerRadius on a media-clipping frame
(`clipContent: true` wrapping an image / video, or roles like
`image-placeholder` / `thumbnail` / `cover-image`) is doing the
rounding work for the photo, not stacking card decoration. Blanket
stripping un-rounded the media against the user's clear intent —
typical pattern is

  card { cornerRadius: 16, clipContent: true }
   └─ image-placeholder { cornerRadius: 12, clipContent: true }
       └─ image

where the inner cornerRadius rounds the photo and the outer rounds
the card frame around it. After the prior pass the inner radius got
stripped (ancestor had cornerRadius too) → square corners on the
photo.

New `MEDIA_CLIP_ROLES` set + `isMediaClipper(node)` helper:
  - role match: image, image-card, image-placeholder, video,
    video-placeholder, media, media-thumbnail, thumbnail, cover,
    cover-image, gallery-item
  - shape match: clipContent: true AND has a direct image / video /
    media-roled child

Either signal preserves cornerRadius. Other decorations (stroke,
shadow) still get stripped — those ARE redundant card decoration
even on a media wrapper, since the photo's own outline + the
ancestor card already provide the visual frame.

Tests: 2 new cases — clipContent + image, and the role-only path
covering image-placeholder / thumbnail / cover-image / gallery-item.
2026-05-11 00:23:08 +08:00
Fini ec4fe44368 feat(pen-core): strip nested card-style decoration on inner frames
User-reported 2026-05-11 "Popular Restaurants" — inspecting the live
canvas via batch_get showed the LLM built each row as
`role:card` (outer) carrying stroke + cornerRadius:16 + 2-shadow
elevation, then nested an inner `Card Info` frame ALSO with
`role:card`, cornerRadius:12, and the SAME 2-shadow stack for the
right-hand text column. The doubled decoration rendered as a
visible "border" / box-in-box that the user called out as the
N-tools being "死板" — element-builders deterministically emit
their own card decoration without knowing they're being nested.

New post-pass `stripNestedCardDecoration` walks the page tree and,
for each non-protected frame:
  - if the frame has stroke AND any frame ancestor has stroke   → strip stroke
  - if the frame has cornerRadius > 0 AND any frame ancestor
    has cornerRadius > 0                                        → strip cornerRadius
  - if the frame has shadow AND any frame ancestor has shadow   → strip effects

Each decoration type is checked independently so e.g. a card inside
a shadow-only ancestor still keeps its cornerRadius. Fills are NOT
touched — stripRedundantSectionFills already handles fill heuristics
and a child fill may be an intentional surface change (dark accent
strip inside a white card).

KEEP_DECORATION_ROLES exempts elements that legitimately carry their
own affordance even when nested in a card: button, chip, search-bar,
input, badge, avatar, switch, etc. Those keep their click-target
visual whether or not the parent is decorated.

Wired in apps/web design-canvas-ops.ts at both finalize sites,
running AFTER stripRedundantSectionFills so the fill pass gets first
crack and this pass cleans up the leftover stroke/cornerRadius/
shadow stack.

Tests: 8 cases — basic strip, partial strip (only matched types),
top-level decoration preserved, protected-role exemption, fills
untouched, deep nesting, asymmetric cornerRadius arrays, no-op
return value.
2026-05-11 00:17:02 +08:00
Fini f93a9437aa fix(ai): edge-padding detector skips when any section has its own h-padding
User-reported 2026-05-11 mobile food design — the page had Header
(search bar + cart), Categories (icon row), and Bottom Nav each
carrying their own horizontal padding by design, but Hero section
left its frame edge-to-edge intentionally. Previous version saw
Hero's missing padding + ≥1 offending child and flagged → root got
+16px gutter on top of every per-section-padded sibling, producing
a visible double-inset / "边距过大" complaint.

Treat any non-fullbleed content child carrying its own h-padding as
a signal that the design has chosen the per-section gutter mode.
Once that signal is observed, skip the root-level recommendation
entirely so we don't double up. Hero / banner / image-bleed roles
remain filtered out of the signal pass via FULL_BLEED_ROLES so a
hero with no padding still doesn't activate the detector.

Test: covers the user's exact pattern (categories + content with
per-section padding + hero without) — previous expectation flipped
from "fire" to "do not fire".
2026-05-11 00:16:40 +08:00
Kayshen-X e2aff6c542 feat(shell): canvas click-to-select + drag-to-move
The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:

* Document::node_at_doc_point walks the active page top-most-first
  and returns the topmost node whose aggregate bounds contain the
  document-space point. Children are tested before parents so a
  click on a button-rect inside a Frame selects the rect, not the
  Frame.

* Document::translate_selected moves the selected node by (dx, dy)
  document px. Leaf nodes update bounds.origin directly; container
  nodes (Group / unbounded Frame) translate every descendant that
  carries bounds, so dragging a Group moves the whole subtree.

* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
  select + start node-drag. Cursor-move converts the screen-space
  delta to document space via the live zoom (no canvas_region
  offset needed because deltas are translation-invariant) and
  calls translate_selected. Release clears the drag.

* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
  with any other tool clears the selection + starts a pan-drag,
  same as before.
2026-05-10 23:23:28 +08:00
Kayshen-X 4bb91af968 style(shell-core): drop dropdown shadow + add toolbar→picker gap
- LocalePicker / ShapePicker no longer paint a soft black offset rect
  underneath; popover background + border hairline are enough to
  read as floating, and the shadow was bleeding into the canvas.

- ShapePicker anchors 8 px to the right of the toolbar PANEL edge
  (not just the slot button), so the dropdown reads as a separate
  surface instead of butting flush against the toolbar's right border.
2026-05-10 23:18:39 +08:00
Kayshen-X f4a569b002 docs(shell): note PropertyPanel editing + Toolbar shape dropdown + desktop crate
* crates/CLAUDE.md — add ShapePicker widget row, PropertyPanel
  X/Y/W/H editing section, Toolbar shape-tool dropdown spec, expanded
  Tool variants, updated UiState bullet listing all the new fields,
  Document::commit_property_edit + Tool::is_shape() helpers.
2026-05-10 23:17:18 +08:00
Kayshen-X 1cacc0eac2 fix(shell-core): chevron sits BELOW the toolbar shape button
Was overlapping the icon at the lower-right of the button. Moved to
the gutter directly below the button, horizontally centered, sized
10 px in muted-foreground. Toolbar now reserves a 10 px extra
bottom slot after ShapeSlot so the chevron has room without bumping
the next button. Hit area extended to include the chevron gutter
so clicking on the caret also opens the picker.
2026-05-10 23:14:56 +08:00
Kayshen-X 79b44e722d style(shell-core): chevron-down on toolbar shape slot
Lower-right corner of the shape button now carries a small
ChevronDown so the dropdown affordance is visible at rest, matching
the TS shape-tool-dropdown's caret. Color follows the active /
muted-foreground split the rest of the slot uses.
2026-05-10 23:13:25 +08:00
Kayshen-X 40f387a774 feat(shell-core): Toolbar shape-tool dropdown — Rect/Ellipse/Polygon/Line/Pen + Icon/Image
The vertical toolbar's shape button is now a compound slot driven by
`Document.ui.shape_tool` (defaults to Rect). Click it to open a
`ShapePicker` dropdown anchored immediately to the right of the
slot — seven rows mirror the TS app's shape-tool-dropdown:

  · Rectangle (Square icon)
  · Ellipse   (Circle)
  · Polygon   (Triangle)
  · Line      (Minus)
  · Icon      (Sparkles, opens icon picker — host follow-up)
  · Import Image or SVG…  (ImagePlus, opens file dialog — host follow-up)
  · Pen       (PenTool)

Picking a shape updates ui.shape_tool (so the toolbar slot's icon
flips), sets doc.tool to that variant, and closes the panel. Click
anywhere else closes silently.

* New Tool variants: Ellipse / Polygon / Line / Pen. Tool::is_shape()
  reports membership in the slot's group so the slot highlights when
  any of them is active.
* New icons: Circle, Triangle, PenTool, ImagePlus (lucide d-strings).
* New widget shape_picker.rs (≤ 280 lines) with hit-test + Widget
  impl + 3 unit tests; ShapeChoice variant for the host to dispatch
  on (Tool / OpenIconPicker / ImportImageOrSvg).
* PropertyLabels-style locale lookup falls back to English literals
  for the row labels (shapes.rectangle / ellipse / polygon / line /
  icon / importImageSvg / pen) — already present in zh.ts.
* Native host wires the open/close/dispatch loop alongside the
  existing locale picker; paint slot z-priority sits below the
  locale picker so a stack of overlays still does the right thing.
2026-05-10 23:12:35 +08:00
Kayshen-X 16e3c9ccf5 feat(shell-core): PropertyPanel i18n + X/Y/W/H input editing
The right-rail inspector picks up locale-aware labels and accepts
keyboard edits on the four most-used number inputs.

* New `PropertyLabels` struct in property_panel_sections; resolved
  once per panel build via `Document::t`. All hardcoded chinese
  section titles (位置/弹性布局/尺寸/图层/填充/描边/效果/导出),
  the 设计/代码 tab strip, the 创建组件 button label, and the five
  尺寸 checkboxes (填充宽/高 / 适应宽/高 / 裁剪内容) now flip with
  the TopBar Globe locale picker. Falls back to English when the TS
  locale tables don't carry a key.

* PropertyPanel now carries `focus / draft / caret_anchor_ms /
  now_ms` so the focused input renders the live edit buffer with a
  primary-color border + blinking caret. `for_selection_at(doc,
  now_ms)` is the new entry point; `for_selection` keeps a
  zero-clock variant for static contexts (tests, etc.).

* New `editable_input_rects` in sections — single source of truth
  for the X / Y / W / H rect layout, shared by paint and
  `PropertyPanel::hit_test`.

* WidgetHostNative wires the full edit cycle: clicking a row
  focuses + seeds the draft from the snapshot, `apply_text`
  filters digits/decimal/leading-minus into the draft, `apply_send`
  parses + commits via `Document::commit_property_edit`, and
  `apply_escape` discards. Click-outside-the-panel auto-commits.
  `next_animation_deadline_ms` now wakes for property focus too so
  the caret blinks at the same 500 ms cadence as the chat input.

* `PropertyFocus` already existed; `Document::commit_property_edit`
  + helper walk now mutate Node.bounds for the X/Y/W/H cases.
  Rotation/opacity/hex inputs accept focus + clear cleanly but are
  no-ops at the node level until the schema grows those fields.
2026-05-10 23:02:58 +08:00
Fini dd231d4ef2 fix(renderer): per-node catch restores canvas save stack
Codex stop-hook on the prior per-node try/catch caught a leak: the
catch logged but didn't roll back canvas state. drawNode pushes
canvas.save() once per ancestor clipStack entry (node-renderer.ts:548)
plus more for rotation / flip (574, 583) and per-shape sub-paths
(701, 1094, 1102). If drawNode throws mid-loop, every save() between
its entry and the throw stays on the stack — the next node's draw
operates inside a leaked clip / leaked transform, and the canvas
either renders nothing or renders to the wrong region.

Snapshot canvas.getSaveCount() before each drawNode call; on catch,
canvas.restoreToCount(saveCount) pops everything back to the
baseline. Wrap the restoreToCount itself in a no-op catch since it
can throw if the snapshot count is somehow above the current depth
(shouldn't happen but guarded so the error reporter still runs).

Net effect: per-node failures are now genuinely isolated. The
canvas state at the start of each iteration is identical to where
the previous iteration left it; one bad node can't smear its leaked
state across the rest of the frame.
2026-05-10 22:54:02 +08:00
Fini 138df33309 fix(renderer): coerce missing shadow numeric fields + isolate per-node draw
User reported "为什么画布是空的" — Bistro DeepSeek generation, layer
panel populated with the root frame but canvas fully blank mid-stream.
The MCP-side document showed children, the UI-side layer tree showed
the root, but no pixels rendered. Two structural issues converged:

1. ShadowEffect TS type marks offsetX / offsetY / blur / spread as
   required, but LLM-emitted shadows routinely omit them
   (`{type:'shadow', blur:3, color:'#0000001A'}` with no spread). The
   prior (and the new shadow-cornerRadius) code multiplied the missing
   field through cornerRadius / RRectXY math, producing NaN. CanvasKit's
   RRectXY throws on NaN inside the WASM module, the throw escapes
   drawNode (renderer.ts:326 had no try/catch), and the entire render
   loop aborts past the bad node — so even unrelated siblings stop
   drawing. User sees a fully empty canvas despite document state
   being intact.

2. The drawNode loop had zero error isolation — a single malformed
   node could blank the whole frame. Structural fragility independent
   of the NaN bug; any future renderer regression would have the same
   symptom.

Two fixes:

 - applyShadowDirect coerces missing / non-finite shadow numeric
   fields to 0 before any math (offsetX / offsetY / spread defaults
   to 0; blur defaults to 0 and clamps non-negative). NaN can't reach
   CanvasKit. The pre-existing drawRect path also benefits — the old
   code happily fed NaN to drawRect via `x + shadow.offsetX -
   shadow.spread`, just relied on Skia's tolerance for some NaN cases.
 - renderer.ts wraps drawNode in per-node try/catch with a console.error
   on failure. A bad node now logs and skips; siblings render normally.
   Defense-in-depth so the next renderer regression doesn't blank the
   canvas.
2026-05-10 22:54:01 +08:00
Fini 6966cb75c1 fix(renderer): shadow uses body's POST-CLAMP corner radius
Codex stop-hook on the previous shadow commit caught: the in-function
clamp `Math.min(maxShadowRX, cornerRadiusX + spread)` looked correct
in isolation but diverged from the body's actually-rendered curve
when cornerRadius exceeded the body's half-extent.

Concrete: 60×60 frame with cornerRadius=100, spread=4.
  - Body's drawRRect at L664 clamps to min(100, 30) = 30. Body curve = 30.
  - Old shadow path: shadowRX = min(34, 100+4) = 34. Shadow curve = 34.
  - Result: shadow corner sticks out past body corner by 4px on all sides
    (visible on canvas — same "尖角" complaint, just at clamp boundary).

Architecture: push the body-clamp out of `applyShadowDirect` and into
the call site, so the function's contract is "input radii are already
the body's rendered radii — I just add spread + clamp to my own
half-extent". Shadow stays in lockstep with whatever the body
actually drew, by construction.

  - Frame / rectangle / image: caller passes
      Math.min(cornerRadius, Math.min(w/2, h/2)) for both rx and ry.
  - Ellipse: caller passes (w/2, h/2) — matches drawOval outline.
  - Path / line / polygon: caller passes 0/0 → plain drawRect.

Tests: pen-renderer 5 / 46 still passes (unchanged functional surface
area; the change is internal to the radii contract).
2026-05-10 22:54:00 +08:00
Fini 29fe2edbbe fix(renderer): shadow rx/ry independent + clamped to half-rect
Codex review on the prior shadow-cornerRadius commit caught two
edges:

Q4 — shadow radius needs upper-clamp like the body's drawRRect.
node-renderer.ts:643 / :1108 already guard `Math.min(cr, maxR)` so
a too-large cornerRadius doesn't degenerate the rrect; the shadow
path was missing the same clamp. A 60×60 ellipse with
spread=4 + cr=34 would emit raw rx=38 which exceeds the
spread-expanded rect's half-extent and visibly distorts. Now clamps
to half-extent of the spread-adjusted rect on each axis.

Q5 — ellipse shadow rx/ry should be independent. The previous fix
mapped ellipse → cornerRadius = min(w,h)/2, which produces a stadium
(pill) shadow when w ≠ h. Splitting the param into independent rx /
ry lets the call site pass (w/2, h/2) for ellipse, matching the
body's drawOval outline for the asymmetric case while staying
identical for symmetric circles.

Frame / rectangle / image stay at rx === ry === cornerRadius. Path /
line / polygon stay at 0/0 → plain drawRect.

Tests: pen-renderer 5 / 46 still passes (no rendering-result coverage
to extend; the change is exercised at the next renderer reload).
2026-05-10 22:53:59 +08:00
Fini b49060d59f fix(renderer): drop-shadow follows cornerRadius / ellipse outline
User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero
images had visibly square-cornered drop shadows poking out from
under the rounded shape. Forensic root cause: applyShadowDirect at
node-renderer.ts:447 was always drawing the shadow as a plain
`canvas.drawRect(...)`, completely ignoring the node's cornerRadius.
A frame with cornerRadius=24 + a subtle drop shadow would render the
rounded body cleanly but stamp a sharp-cornered shadow rectangle
just behind it, with the rectangle corners visible past the rounded
outline.

Fix: pass the node's cornerRadius into applyShadowDirect; when > 0
use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the
shadow's rounding stays parallel to the node's rounding (the +spread
correction keeps the visible curve aligned when spread expands /
contracts the bounds).

Ellipse / circle nodes (avatars, status dots) get cornerRadius =
min(w,h)/2 from the call site so their shadows render as stadium /
circle. Asymmetric-aspect ellipses get a stadium approximation
rather than a true ellipse — accepted simplification, the common
case is symmetric (avatar / dot).

Path / line / polygon nodes have no cornerRadius and fall through
with cr=0 — rectangular shadow stays correct for them.

This is a renderer-layer fix that detector-only paths can't reach;
ships in the same session as the typography / spacing detectors so
the user sees end-to-end aesthetic improvement on the next rebuild.
2026-05-10 22:53:58 +08:00
Fini d435fc53a7 Merge branch 'v0.8.0' of github.com:ZSeven-W/openpencil into v0.8.0 2026-05-10 21:37:11 +08:00
Kayshen-X db69fc5fc7 refactor(shell): promote inspector_window to openpencil-desktop binary crate
The native runner outgrew the `examples/` slot — it owns DPI tracking,
caret-blink animation timer, panel-resize cursor, the full Cmd+wheel /
PinchGesture / Pixel/LineDelta dispatch table, etc. None of that is a
sample, so it's been promoted to a real crate.

* New crate `crates/openpencil-desktop/` with a single `[[bin]]`
  target. Depends on `openpencil-shell-native` (lib) + winit +
  skia-safe (gl), gated to macOS / Linux / Windows.
* `examples/inspector_window.rs` removed; equivalent code lives at
  `crates/openpencil-desktop/src/main.rs` with the structs renamed
  (DesktopApp / paint) and the doc-block rewritten as a runner spec.
* Run command: `cargo run -p openpencil-desktop --release`. Old
  command (`--example inspector_window`) is gone.
* Workspace glob `crates/*` already picks up the new crate, no
  Cargo.toml workspace edit needed.
* Docs: crates/CLAUDE.md updated with the new crate row and runner
  section retitled "Desktop binary". Top-bar layout test renamed +
  uses the TOP_BAR_HEIGHT constant so future height tweaks stop
  breaking it.
2026-05-10 19:50:10 +08:00
Kayshen-X 967201162a feat(shell): AA round-rects + Layer/Property dividers + resizable rails + smaller chrome
* Native fill_round_rect now sets anti_alias(true) — was the source of the
  stair-stepped tool-button corners. Mirrors the AA flag we already had on
  stroke_round_rect / stroke_line / stroke_svg_path.

* LayerPanel paints a right-edge hairline (so the rail reads as a distinct
  surface from the canvas) plus an inset hairline between the Pages and
  Layers sections (matches the TS LayerPanel border-t).

* Layer + Property panel widths are now first-class Document.ui state
  (`layer_panel_width` / `property_panel_width`, defaults 240/280).
  Native host detects ±4 px gutter clicks on the panel edges, drags the
  width inside [180, 480], and the inspector_window runner flips the
  cursor to EwResize while hovering or actively resizing.

* Web host expressions threaded onto the same UiState fields for parity;
  drag wiring on web is a follow-up.

* TopBar trimmed: 48 → 40 px height, 32 → 28 icon button, 18 → 16 icon —
  the chrome reads less heavy at default zoom.

* Drops the now-unused PropertyPanel `Copy` derive (UiState carries a
  String draft) and lowers the toolbar (44×32) and topbar (40 px) so the
  rails feel tighter overall.
2026-05-10 19:42:46 +08:00
Kayshen-X 333403a0c7 docs(shell): note empty-agent chip, locale picker, multi-script, chat parity
* crates/CLAUDE.md — register LocalePicker widget, canvas_surface token,
  ai.tipSelectElements key, multi-script per-codepoint font cache.
* TopBar Globe section now describes the 44 px globe-plus-chevron
  compound and the click-anywhere-to-close behaviour.
2026-05-10 19:34:28 +08:00
Kayshen-X bf8f156a7b feat(shell-core): canvas surface, slimmer toolbar, TS-style chat input
* Theme: new `canvas_surface` token (#181818 dark / #fafafa light) —
  CanvasViewport now paints the surface with this distinct shade so
  the canvas reads as its own surface rather than blending into the
  chrome background.

* AI chat panel: rebuilt the bottom of the panel to mirror the TS
  reference. Single hairline separator between body and input,
  borderless 14 px textarea with the same caret blink driver,
  dedicated 40 px toolbar carrying ✦ Default ▾ on the left and
  attach + send (24 px primary square) on the right. ai.tipSelectElements
  string wired in (used by the empty-state body).

* Toolbar (vertical floating column): trimmed from 48×36 to 44×32 so
  the tool buttons feel less heavy at default zoom levels.
2026-05-10 19:33:06 +08:00
Kayshen-X 7b800f67bd feat(shell): chevron + close-on-globe + multi-script font fallback
TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.

Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.

Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
2026-05-10 19:26:48 +08:00
Kayshen-X 4f95c0860b feat(shell-core): TopBar Globe → locale picker dropdown
Adds a LocalePicker widget that paints a vertical list of all 15
native-script locale names (English / 简体中文 / 繁體中文 / 日本語 /
한국어 / Français / Español / Deutsch / Português / Русский / हिन्दी
/ Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia) with a Check icon
and primary tint on the active row.

Globe click toggles `Document.ui.locale_picker_open` instead of
silently cycling. Row click sets the locale + closes; clicking
outside the panel closes silently. Picker paints on top of every
other layer (chat / status / canvas) so it never gets covered.

Native + web hosts share the implementation via
shell-core::widgets::LocalePicker; `TopBar::globe_rect` exposes
the icon-button anchor so the panel stays glued under the icon
even after a viewport resize.
2026-05-10 19:21:36 +08:00
Kayshen-X 3b77cbb211 style(shell-core): drop border ring on Agents 与 MCP chip 2026-05-10 19:16:46 +08:00
Kayshen-X e40c3a6e1c feat(shell-core): TopBar empty-agent chip — 'Agents 与 MCP' affordance
Default Document has no connected agent, so for_document now sets
agent_count = 0 and the chip switches to the empty-state look:
LayoutGrid icon + 'Agents 与 MCP' label (TS topbar.agentsAndMcp /
en topbar.agentsAndMcp). Active state (agent_count >= 1) keeps the
Sparkles + green dot + 'N agent' look.

Chip width is now driven by RenderBackend::measure_text so the
border ring tracks the actual rendered string instead of a
per-char estimate.
2026-05-10 19:11:06 +08:00
Kayshen-X c6020e3aeb docs(shell): document the i18n + theme toggle infrastructure
crates/CLAUDE.md gains an 'i18n' row + 'Theme + i18n' section that
covers Document::theme()/t() + the 15 locale tables generated
from TS. Includes the convert-locales.py re-run command for
contributors who change TS strings.
2026-05-10 19:08:17 +08:00
Kayshen-X ba6f28f673 chore: gitignore tools/__pycache__/ + remove the .pyc that snuck in 2026-05-10 19:04:55 +08:00
Kayshen-X 2a3a6e96d6 fix(shell-core): convert-locales handles multi-line + double-quoted values
Earlier convert-locales.py was line-based + single-quote-only, missing
~16 keys per locale where:
  - the value spans onto the next line ('long.key.name':\n    'value')
  - the value uses double quotes for English contractions ('topbar.dontSave': "Don't Save")

Switch to a regex.finditer over the whole file with multi-line +
double-quote alternation. All 15 locales now report 706 keys each,
matching the TS source (apps/web/src/i18n/locales/*.ts).

Stop-hook: 'locale import is incomplete'.
2026-05-10 19:04:38 +08:00
Kayshen-X 544a307fd9 feat(shell-core): import all 15 TS locale tables verbatim
Replaces the hand-rolled 25-key i18n.rs with 15 generated locale
modules (~700 keys each) mirrored from
apps/web/src/i18n/locales/*.ts via tools/convert-locales.py.

Locale enum expanded to match the TS dropdown:
EnUs / ZhCn / ZhTw / Ja / Ko / Fr / Es / De / Pt / Ru / Hi / Tr /
Th / Vi / Id (15 total). Each carries its native-script
display_name() (English / 简体中文 / 繁體中文 / 日本語 / 한국어 /
Français / Español / Deutsch / Português / Русский / हिन्दी /
Türkçe / ไทย / Tiếng Việt / Bahasa Indonesia).

Globe icon click cycles all 15 via Locale::next() (round-robin
through Locale::ALL).

Chrome key references updated to TS dot.case naming so the same
key resolves on both sides:
- topbar.untitled → common.untitled
- layer_panel.pages → pages.title
- layer_panel.layers → layers.title
- chat.new_chat → ai.newChat
- chat.start_with_ai → ai.tryExample
- chat.input_placeholder → ai.designWithAgent

Generator script lives at tools/convert-locales.py (re-run when
TS strings update). Each locale .rs file is ≤ 710 lines (under
the 800-line ceiling). Cross-locale fallback: missing keys try
EN before falling through to the key itself.

68 lib tests pass (+1 i18n fallback test).
2026-05-10 18:57:20 +08:00
Kayshen-X c5d408d6be style(shell): cargo fmt --all (rustfmt-clean)
Stop-hook fix: codex flagged Rust files as not rustfmt-clean.
Run cargo fmt --all across openpencil-shell-{core,native,web}
+ wasm-libc-shim. 67 lib tests still pass, native + web cargo
check clean.
2026-05-10 18:47:33 +08:00
Kayshen-X 9506341da0 feat(shell-core): thread localised strings through LayerPanel + AIChatPanel paint
Theme + locale toggle infrastructure landed in ed36df56, but the
visible chrome strings were still hardcoded so flipping the Globe
icon didn't actually change anything. Now:

- LayerPanel resolves '页面' / '图层' from doc.t() at construction
  and stores as String fields; paint reads those instead of
  hardcoded literals.
- AIChatPlaceholder resolves 'New Chat' / '用 AI 开始设计' /
  '用 Agent 设计…' the same way; paint_examples takes the hint
  label as a parameter.

TopBar 'untitled' label was already wired (for_document uses
doc.t). 67 lib tests still pass.
2026-05-10 18:40:46 +08:00
Kayshen-X 91d9e99a94 feat(shell): theme + locale toggle wired to TopBar Sun + Globe icons
Sun click flips dark↔light; Globe cycles ZhCn↔EnUs. Both pipe
through Document.ui (theme_mode + locale) so any widget builder
that reads doc.theme() / doc.t(key) reflows immediately.

- Document.ui.theme_mode: ThemeMode { Dark, Light } with
  ThemeMode::flipped()
- Document.ui.locale: Locale { ZhCn, EnUs } with Locale::next()
- Document::theme() returns dark/light from ui.theme_mode
- Document::t(key) calls i18n::translate with ui.locale
- New i18n module — flat per-locale match tables, ~25 keys for
  chrome strings (TopBar / LayerPanel / PropertyPanel / chat).
  Unknown keys fall through to the key itself for debug visibility.
- TopBar.hit_test resolves Sun → ToggleTheme + Globe → ToggleLocale
- WidgetHost (native + web) routes both new TopBarHit variants
- LayerPanel / PropertyPanel / CanvasViewport / Toolbar /
  AIChatPlaceholder constructors swapped Theme::dark() →
  doc.theme() so the chrome flips together
- TopBar / StatusBar gained for_document(doc) builders
- StatusBar.zoom_percent now reads from Document.viewport.zoom

67 lib tests pass (+3 i18n unit tests).
2026-05-10 18:37:41 +08:00
Kayshen-X 71a3b9010b fix(shell-native): refresh host clock at top of every WindowEvent
Stop-hook fix: 'caret reset can use a stale clock'. set_now_ms was
only called inside RedrawRequested, so apply_text / apply_backspace /
apply_press routed mid-frame stamped caret_anchor_ms with the
previous frame's now_ms. The result: caret reset visually appeared
delayed by up to one redraw interval (rare but inconsistent).

Refresh self.clock_start.elapsed() at the top of every WindowEvent
so any apply_* called inside the match arm sees the current
timestamp. Drop the redundant inside-RedrawRequested refresh.
2026-05-10 18:24:58 +08:00
Kayshen-X 0c84202798 feat(shell): caret blink driven by jian-core::anim primitives
Sinks the blink phase logic into vendor/jian (jian-core::anim) so any
host can wire the same square-wave timing instead of reimplementing
per-product. Both OpenPencil chrome and Zode TUI consume the same
helpers.

- vendor/jian bumped to head with new `jian_core::anim` module
  (blink_visible / next_blink_flip_ms, 9 unit tests)
- ChatState: `caret_anchor_ms` resets on focus / keystroke /
  example fill so the caret reappears solid right after the user
  acts, not mid-fade
- AIChatPlaceholder.now_ms threaded from host; paint computes
  caret visibility = focused && jian_core::anim::blink_visible
- AIChatPlaceholder caret X uses RenderBackend::measure_text for
  pixel-accurate trailing edge (replaces the 7px / 13px guess
  per char that drifted on Roboto + Noto-CJK)
- WidgetHostNative.set_now_ms / chat_focused / next_animation_
  deadline_ms surface; runner refreshes from a single Instant
  anchor + sets ControlFlow::WaitUntil at the next blink flip
- inspector_window: new_events handles ResumeTimeReached → request
  redraw so winit actually wakes for the next frame
2026-05-10 18:19:46 +08:00
Kayshen-X a7f9eb120f style(shell-core): selected layer row uses primary-tinted bg + primary text/icon
TS LayerPanel renders the selected row with bg-blue-500/15 + primary
text color + primary icon color (apps/web/src/components/panels/
layer-item.tsx). My panel was using theme.row_selected (gray #262626)
+ foreground text, which read as 'darker gray on dark gray' — not
the clear 'this is selected' affordance the TS app gives.

- Add Theme.row_selected_primary (rgba(0x3B82F6, 0.18) — blue 15%)
- LayerPanel: selected layer row uses row_selected_primary bg,
  primary text + primary icon
- Page rows still use the neutral row_selected (matches TS where
  the active page tab is also subdued gray)
2026-05-10 18:08:47 +08:00
Kayshen-X ed48e1f139 fix(shell-core): 创建组件 icon Diamond → Component to match TS
TS imports both Diamond (instance indicator) and Component (cluster
of 4 small diamonds, used for the 创建组件 button). I picked the
single-diamond Diamond by mistake; the button uses Component.
2026-05-10 18:05:05 +08:00
Kayshen-X 0c0131653e style(shell-core): refine property panel to match TS — section labels foreground, full-width dividers, taller inputs
Previous panel had muted section labels + inset dividers + 26px inputs;
TS reference (apps/web/src/components/panels/right-panel.tsx) uses
foreground-tinted headers, edge-to-edge dividers, and 30px inputs.

- INPUT_HEIGHT 26 → 30 (matches TS Input render height)
- SECTION_HEADER_HEIGHT 28 → 24 (TS  tighter strip)
- SECTION_GAP 1.0 → 8.0 (proper breathing room between divider and
  next label)
- Section labels: muted_foreground → foreground (matches TS)
- Section dividers: PAD_X inset → full canvas-edge-to-edge

Visible polish difference vs the TS screenshot is now mostly the
input baseline / right-padding, which a future text-measure API
will fix once we have skia-side advances.
2026-05-10 18:04:09 +08:00