Feat/inspector panel redesign - #468
Conversation
…port
Three interlocking pieces of media framing, plus a first-party photo source.
Crop and focus
- Fix the crop rectangle being draggable only by its edges: the focus ring
was a transparent 60cqmin <button> covering most of the selection, so
grabbing the middle grabbed the focus, not the crop. The ellipse is now
pointer-transparent and only two small pucks take input.
- Crop handles go from 4 corners to 8 (corners plus edge midpoints), so one
edge can be pulled without dragging its neighbours. Arrow keys nudge,
Shift coarsens.
- The focus point becomes a focus ELLIPSE (centre plus extent) that can be
moved and resized. Both drags are delta-based, so grabbing the rim handle
no longer shrinks the ellipse by sqrt(2) on the first frame.
- Previews switch from contain to cover and are positioned by the same
object-position helper the publisher uses, so they show what the page
shows, cuts included, instead of letterboxing the decision away.
Image fit
- base.image gains an objectFit control (Default / Cover / Contain). Nothing
in the published output set object-fit before this, which meant the stored
focus point was inert: object-position only bites once something crops the
image. objectFit is per-instance while crop and focus live on the shared
asset, because the same photo is legitimately a cover-cropped hero on one
page and a contained thumbnail on another. ImageEditor applies both through
the same helper so the canvas cannot disagree with the published page.
Unsplash import
- Optional, enabled by UNSPLASH_ACCESS_KEY; the feature is hidden entirely
when unset. Browse or search from the Media toolbar, from the image picker
modal, or from the viewer to replace an existing asset's file.
- The key never reaches the browser. Import takes a photo ID, never a URL:
the server re-fetches the photo and uses the download URL from Unsplash's
own response, then runs the bytes through acceptUploadedMedia so an
Unsplash photo gets exactly the same sniffing, size ceiling, and variant
ladder as a hand-dragged file.
- The SSRF-guarded downloader was extracted from the MCP upload_media tool
into remoteImageFetch.ts rather than copied, so both outbound-image paths
share one set of guards.
- Both Unsplash licence obligations are implemented, not optional:
UTM-tagged attribution rendered on every tile and stored on the imported
asset, and the download-tracking ping on import. unsplashClient.test.ts
pins both, because breaking either leaves the app working perfectly and
silently out of compliance.
Notes
- No new migration. focus_json simply holds four fields now; parseFocus reads
pre-existing {x, y} rows as a centred default-sized ellipse. That is the one
boundary that tolerates the older shape, because it is the only place older
rows arrive.
- crop and unsplash icons are hand-authored in vendor/ via the documented
IN_HOUSE_ICONS path; the upstream catalogue carries neither a crop glyph
nor brand marks.
- Architecture gates updated with justifications: button-primitive-usage
(crop and focus handles) and cms-handlers-capability-gated (the two new
non-routing helper modules).
Verification: bun run build, bun run lint (both clean), plus the media,
module, publisher, geometry, Unsplash-client and design-token suites.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an icon picker to the `base.svg` property control, so an icon can be placed on a page by choosing it instead of pasting SVG markup. No new module. An icon on a page IS inline SVG, and `base.svg` already stores markup, sanitises it at the publisher boundary through DOMPurify, inherits `currentColor`, and is styleable by classes. A `base.icon` module would emit identical HTML — two implementations of one thing. Picking an icon simply fills in the prop that already exists. The picker browses DATA, not components. `scripts/build-icon-manifest.ts` extracts each vendored icon's path geometry into `src/ui/icons/iconManifest.ts`, and the grid renders `<path d>` directly. That is what keeps this from tripping the two rules a picker normally collides with: nothing imports an icon component, so there is neither a mass import nor a lazy `Icon` wrapper. `direct-icon-imports` and `no-third-party-icons` both still pass untouched. The manifest generator is deliberately separate from `sync-icons.ts`: that one refuses to run without a checkout of the private upstream catalogue, while this one only reads what is already vendored. So the manifest is regenerable on any machine, and the day the full catalogue is vendored the picker needs no change — `bun run icons:manifest` and the grid grows from 136 icons to the whole set. Known ceiling, marked with a `ponytail:` comment: the grid renders every match at once, which is fine at 136 icons and will need a windowed list at ~4,000. Verification: bun run build, bun run lint (both clean), and the icon and design-token gates — direct-icon-imports, no-third-party-icons, vendor-icons-fresh, css-token-policy, no-css-var-fallbacks, button-primitive-usage (14 tests, all passing). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backup commit so uncommitted work stops living on a single disk. This mixes several in-flight threads and is deliberately NOT a reviewable unit — split it before opening any PR: - icon packs: generated multi-pack icon set, IconPicker pack switching, iconManifest.ts removed in favour of packs/registry.ts - visual components: variant/class bindings, agent component + page tools - properties panel: Variants section for editing variant bindings in UI - media: ImageViewer aspect-ratio + blurhash placement fix - misc: svg sanitize, db client, AI tool schema changes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Working list for this fork: remote layout (origin = fork, upstream = original), what is already consolidated on main, the test-failure triage, and the deferred project-rename decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the native <input type="color"> behind ColorInput, which could express neither alpha nor the site's own colour tokens. ColorInput keeps its role as the swatch primitive but is now a trigger for a portalled popover; every existing call site gains the new UI without migration. The picker itself is a separate src/ui/components/ColorPicker/ primitive and is store-agnostic — tokens and token creation arrive as props, so src/ui/ keeps its no-editor-imports property. - SV square with pointer capture, arrow-key accessible, role="slider" - hue and alpha sliders, alpha over a checkerboard - HEX/RGB/HSL text field with a SegmentedControl notation switch - opacity percentage field - feature-detected EyeDropper button (hidden where unsupported) - searchable list of framework colour variables - "New Style" mints a token from the current colour and binds to it Picker gradients ride inline --color-picker-* custom properties; the absolute sRGB references (hue track, checkerboard, white/black ramps) are tokens in globals.css and deliberately shared across themes. Dialog's hardcoded z-index becomes --dialog-z-index so the layering with the picker (9500) is explicit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Merged work introduces 0 regressions and fixes 199 pre-existing Windows failures (DbClient.close() ends the EBUSY temp-DB teardown breakage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…he canvas Replacing an asset (file upload or Unsplash import over an existing asset) wrote the new bytes to a fresh nanoid storage path and swept the old ones, so every page tree, style rule and published page pointing at the previous URL turned into a 404. The docstring and docs already promised a stable public path; the implementation had drifted away from it. - acceptReplacementMedia now hands the adapter the PREVIOUS storage path so the write lands in place (adapters are contractually idempotent on suggestedStoragePath). A fresh path is minted only when the MIME changes, because the extension is what fixes the served Content-Type. - Both sweeps skip whatever the new write already sat on top of. Variant paths are a deterministic function of the parent storage path, so an in-place replace rewrites the same names and the old cleanup would have deleted the ladder it had just built. - Stable URLs mean the URL no longer identifies the bytes, so admin surfaces stamp media urls with the asset's replacedAt. Never-replaced assets keep a bare url. - refreshCmsMediaAssetCache fetches then swaps instead of clearing and waiting: mounted canvas modules only re-run ensureList when their effect re-fires, so a bare clear left them on an empty snapshot until a page reload. That is why a replaced background image needed F5 to appear. - The background resolver builds its memo signature from the stamped urls, otherwise a replace, which keeps every raw path identical, never busts it. Also: the Media page Unsplash button uses the Unsplash icon, and the viewer menu item drops its trailing ellipsis. Verification: bun run build, bun run lint, bun test on the media suites (54 pass). The repo-wide run has 62 pre-existing failures on this machine (Windows symlink EPERM in the publisher, plugin scheduler DB) unrelated to this change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…perf for ColorInput/ColorPicker Rebuilds the colour swatch popover as a Framer-style panel and extends it with gradient authoring: - ColorPicker gains a Solid / Linear / Radial / Conic fill-type row (icon tabs, not a text segmented control) and a stop strip: click to add a stop, drag to move it, double-click to remove it (min. two stops). New module gradientMath.ts parses/formats the three gradient CSS shapes; fillState.ts models the solid-vs-gradient state the picker's colour surface edits. - BackgroundImageControl's Custom mode gets a gradient swatch next to the raw text input, wired with `gradients` — this is the only place gradients are currently editable. background-color stays solid-only, correctly: CSS does not allow a gradient there. Verified end-to-end (RTL) that Custom → swatch → Linear produces a live linear-gradient(...) string that reaches the raw text field. - ColorInput's popover is now a draggable, portalled floating panel: opens beside the trigger (not anchored under it), has a drag handle + × close, and stays open while a colour is picked (native EyeDropper, canvas clicks, sibling fields) — only ×/Escape/outside-pointerdown closes it, which also guarantees at most one panel is open at a time. Panels avoid the editor's sidebars (`data-floating-obstacle`) and the viewport edges with soft rubber-band resistance while dragging, gliding back on release. - Performance: the picker is optimistic. Local state (handles, stops, text fields) updates every frame; `onChange` — which drives the store, the CRDT and a live canvas repaint — is leading+trailing throttled (~15 Hz) so a drag doesn't pay that cost every pointermove. All picker drags (saturation square, hue/alpha tracks, stop strip) are additionally frame-throttled, and panel dragging writes position directly to the DOM instead of through React state, so nothing in the picker's subtree re-renders while the pointer moves. - The opacity field now reads like a native "100%" chip (text value that carries the % glyph, hover-revealed bare stepper, no native browser arrows) instead of a number input with a detached unit. - ClassPropertyRow's remove-× moved from the label column to the row's trailing edge and lost its pill background, matching the reference; the backgroundImage row (which has its own segmented-control clear affordance) no longer shows a redundant overlay ×. Known gap: gradients are NOT reachable from the plain colour swatches (background-color, text-color, border-color, …) — only from Background image → Custom. If the intent is a single unified Fill control that switches a property between solid/gradient/image the way Figma/Framer do (converting background-color ↔ background-image under the hood), that is a separate, larger follow-up not built here. Verification: bun run build, bun run lint, bun test on the UI + relevant architecture suites all pass. Two pre-existing failures on this machine (admin-spacing-token-policy, in MediaPickerField/IconPicker — files this change never touches) are unrelated. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The gradient tabs existed but were only reachable from Background image →
Custom, so opening the background COLOUR swatch — the obvious place to set a
fill — offered solids only. Fixed at the source rather than by duplicating
the picker.
CSS has no single fill property: a solid belongs on `background-color`, a
gradient on `background-image`, and writing a gradient into
`background-color` is dead CSS the browser silently drops. So the background
row now renders a BackgroundFillControl that owns both keys and routes each
edit to whichever one is correct:
- solid → `background-color`, retiring a gradient that was on
`background-image`. An image `url(...)` is deliberately left
alone: a colour behind an image is a legitimate pairing and the
Background image row still owns it.
- gradient → `background-image`, clearing `background-color` so a leftover
colour cannot paint over the gradient it replaced.
Reading back mirrors that — a gradient on `background-image` wins over
`background-color`, because that is what the element actually paints — and
the row's set-state and clear-× follow the pair, so a gradient-only fill is
still "set" and one × retires both keys.
Supporting changes:
- Both writes travel in ONE patch via a new `onChangeMany`, threaded like the
existing `onClearProperties`. Both composers already apply patch objects,
so the colour↔gradient swap is a single store commit and a single undo
step; the canvas never renders a frame with both set.
- `gradients` is threaded through ColorControl's chain (ColorValueInput →
TokenizedColorField → ColorInput), and the blur validator accepts gradient
strings where they are legal — `CSS.supports('color', …)` rejects a
gradient, so a typed one used to silently revert.
- Numeric picker fields are now one component, UnitStepperInput: the unit
lives inside the value ("100%", "180deg") with a hover-revealed bare
stepper. The angle field had drifted to a stock number input with an
always-visible spinner and read as a different control; both now match.
Not included: gradients stay off `color`, `border-color` and friends, where
CSS genuinely cannot hold them.
Verification: bun run build, bun run lint, and bun test on the UI + admin
suites (285 pass) all clean, including four new tests pinning the fill
routing in both directions. The 8 architecture-gate failures on this machine
are pre-existing (Windows symlink EPERM, CodeMirror, scheduler, and spacing
tokens in MediaPickerField/IconPicker — files this change never touches);
`Module size budgets` now passes again after the picker split.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aiming a gradient meant typing degrees into a sidebar field and guessing. The canvas now draws the CSS gradient LINE across the selected element with one handle per colour stop, so it can be aimed and its stops slid where the user is actually looking: - drag a STOP handle → slides that stop along the line - drag the END cap → rotates the gradient (Shift snaps to 15°) Radial gradients have no direction, so their end cap is omitted; the stop handles still ride the horizontal radius CSS uses by default. Geometry is a pure, tested module rather than inline math, because the one thing that must be right is where the handles sit. CSS defines the gradient line as passing through the box centre with length |w·sinθ| + |h·cosθ| — NOT the box diagonal — so a plausible diagonal implementation would misplace every handle at every angle except the exact diagonals. The tests pin non-diagonal angles for that reason, plus the direction convention (screen y is down while CSS angles measure from "up") and the round-trip between a stop position and its point. Writes go through a new `useActiveStyleTarget`, extracted so the panel's "where does a style edit land?" rule has ONE home: active class rule, breakpoint/condition override, or the node's inline styles. Duplicating that resolution in the canvas would have drifted the moment either side changed, and a gizmo that silently wrote to a different layer than the sidebar is a nasty class of bug. The hook returns the resolved bag too, so the gizmo reads the value it is about to edit from the same place it will write it. Rendering follows the selection rings exactly: measured through the shared `createCanvasOverlayMeasureSession` (so it tracks zoom, pan, scroll and layout shifts) and portaled beside them, escaping the breakpoint viewport's overflow without being scaled by canvas zoom — handles keep a constant grab size however far out the user is zoomed. The layer is click-through; only the handles take pointer events, so the gizmo never steals a canvas click. Drags are frame-throttled, matching the picker. Verification: bun run build, bun run lint, and bun test across the UI + admin suites (295 pass) are clean, including 10 new geometry tests. The two remaining architecture-gate failures are pre-existing spacing-token violations in MediaPickerField/IconPicker — files this change never touches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Frees 5433 for another local stack. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… swatch rows Adds the shared pieces the redesigned inspector is built from: FloatingPanel (the draggable shell the colour picker and border popout ride in), StepGroup, SwatchRow, a shared field-size scale, and the inspectorGlyphs sheet. Input gains scrubbable number fields; Section, Select, SegmentedControl, ControlRow, Button and ContextMenu are restyled to the neutral chrome the panel expects, with the new tokens added to globals.css. Drops the vendored pixel-art alignment/arrow icons the panel no longer imports (icons:sync), and updates docs/design.md + docs/reference/ui-primitives.md. Verification: bun test src/__tests__/ui, bun run build, bun run lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds useCanvasFreeMoveDrag: when the selected element is absolutely/fixed positioned, dragging it on the canvas writes inset offsets instead of falling through to the tree reorder drag. Extends the gradient gizmo with the stop and angle handles the redesigned background control drives. Verification: bun test src/__tests__/ui/gradientGizmoGeometry.test.ts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reworks the properties panel to the prototype layout: Size, Layout, Typography, Effects and Styles sections grouped by scope, a section add menu, inset/spacing box controls, a border popout row backed by the floating panel shell, and tokenised colour fields wired to the gradient-capable colour picker. Adds the effects model behind the shadow/filter rows and a `visibility` entry to the CSS property bag. Docs: docs/features/inspector-panel.md plus the remaining-work list; parity with the prototype is gated by inspector-prototype-parity.test.ts. Verification: bun test, bun run build, bun run lint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cleanup Continues the inspector panel redesign and folds in the dedup pass it exposed: - Inspector: unit fields, value-editor popouts, spacing-box side scrub, and a floating-panel host shared by the popouts. - Canvas: spacing highlight overlay + geometry, resize drag, free-move drag, with tests for each. - Dedup: one shared clipboard / formatBytes / formatDateTime / slugify in src/admin/lib, clamp + portalRoot in src/ui/lib, ControlRow primitive. Deletes the per-page copies they replaced. - Prunes dead deps and scripts (secretlint, dependency-cruiser, @floating-ui/dom, unused bench aliases) plus their knip entries. - Docs updated alongside (design, editor, inspector-panel, canvas-dnd, design-tokens, page-tree). Two gates moved with the code: the AdminCanvasEditorBody bundle budget is raised 780 KB -> 880 KB for the redesign's weight, and the dynamic binding test goes back to asserting the real "Insert binding for Text" label (a "Content" row was asserted but never implemented). Verified: bun run build, bun run lint clean. bun test — 6796 pass; the 93 failures are pre-existing at d93755f (confirmed against a HEAD worktree): Windows symlink/path-separator gates, publish artefact slot tests, and plugin scheduler DB tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Hi @0x6Star3s Is this ready for the review? I don't see any PR description, which should be fixed. But since it's published, let me comment. I briefly checked it yesterday because I was curious and it does look solid! It will be a big step forward in the capabilities and design of the properties panel. However, I am not sure how I feel about the change of design of the inputs in general, now the inputs, and selects are looking just like buttons. I like the layout and composition, but I would probably return to the original design style - buttons solid, inputs outlines. I also liked the button-design on the section headers. But this is fine too. I am not closing the door on the new design of the inputs, but it will affect many more places, so it needs to be checked and judged properly. I noticed inconsistencies that would have to be resolved, like the segment component navigation for Styles / Attributes is different than the one we already have in other places like in Explorer panel on the left side. Also, there will be more tabs in the future, so I would return to simple small links as before too, or just tiny button tabs. Then the icons are not from our kit, but that is something that I may need to resolve as you don't have an access to the full set of icons, which goes beyond to what is currently in the repo. I would be happy if someone else will chime in too, as this is a large set of changes that will affect every user. |





Summary
Describe the change and why it is needed.
Verification
bun run buildbun testbun run lintChecklist