Skip to content

Feat/inspector panel redesign - #468

Open
0x6Star3s wants to merge 18 commits into
CoreBunch:mainfrom
0x6Star3s:feat/inspector-panel-redesign
Open

Feat/inspector panel redesign#468
0x6Star3s wants to merge 18 commits into
CoreBunch:mainfrom
0x6Star3s:feat/inspector-panel-redesign

Conversation

@0x6Star3s

Copy link
Copy Markdown

Summary

Describe the change and why it is needed.

Verification

  • bun run build
  • bun test
  • bun run lint
  • Docker/deployment check, if relevant

Checklist

  • Tests cover behavior changes.
  • Docs were updated when behavior, config, deployment, or public surfaces changed.
  • No compatibility shim was added for old pre-release behavior.
  • No secrets, local databases, uploads, or generated artifacts are included.

0x6Star3s and others added 18 commits August 18, 2026 15:16
…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>
@DavidBabinec

Copy link
Copy Markdown
Contributor

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.

Here're the screenshots:
image
image
image

@MelmacAlpha

Copy link
Copy Markdown

I’ve just tried this out, and from a designer’s perspective and as a long-time user of Figma and various other design tools I think it’s fantastic. This would be a huge step forward for the interface. I fucking love it!

Yes, there are still a few inconsistencies, and some areas need a little more polish. I’d also give the inputs an outline, so they're easier to spot and it makes the UI feel a little less packed (see shots before/after). I’d maybe also reduce the gaps slightly to make everything a little more compact. But overall, this includes so many things I found myself wishing for while using Instatic intensively over the past few days.

By the way: I absolutely love Instatic. I only discovered it last week, but I’ve been working with it day and night ever since, locally on my macbook building websites. It’s sooo good! And whenever something doesn’t work or I need some new functionality, Codex just builds it for me, creates a plugin, or fixes the bugs. It’s literally a designer’s wet dream come true.

Thank you so much for this!

v1 v2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants