import { useMemo, useRef } from 'react';
import { BackgroundRemovalButton } from './BackgroundRemovalButton';
import { IMAGE_FILTERS } from '../../constants/imageFilters';
import '../../styles/ElementToolbar.css';
/**
* Floating element toolbar.
*
* What's here (image-only items marked):
*
* • Flip H / Flip V — image
* • Crop button — image; enters App's crop mode,
* toolbar switches to Apply/Cancel
* • Remove background (compact) — image; lives in the top row as
* a peer of Flip and Crop now that
* the old wide pill has been
* superseded by the on-canvas tools
* • Edit text (mobile only) — text; pencil equivalent
* • Opacity slider — any
* • Size slider (mobile only) — any
* • Rotation slider (mobile only) — any
* • Brightness / Contrast — image; stacks on top of filter
* preset
* • Filter chips — image
*
* Most layer ops (delete, duplicate, lock, up/down) live in the
* LayersPanel's per-row buttons now. Snap toggle moved to the zoom
* controls toolbar.
*
* What used to be here but isn't anymore
* ──────────────────────────────────────
* • "Edit Photo" wide pill button. It launched the Filerobot
* full-screen photo editor for brightness / contrast / crop /
* filters. All of those are now reachable directly from this
* toolbar (sliders + crop overlay + filter chips), so the
* wide pill became redundant and visually heavy. The
* `onEditPhoto` prop is still accepted by this component for
* backward-compat with callers, but it's unused — callers that
* pass it just get a no-op.
*/
export function ElementToolbar({
element,
onUpdate,
onCommit,
// Accepted but unused — see docblock. Kept in the signature so
// existing call sites in App.jsx don't error.
// eslint-disable-next-line no-unused-vars
onEditPhoto,
onEditText,
isMobile = false,
// Crop API. When `cropping` is true, the toolbar replaces its
// normal content with Apply / Cancel buttons targeting onApplyCrop
// and onCancelCrop. When false (default), the Crop button is
// present alongside the flip controls. Wiring all of these is
// optional — when the callbacks are missing, the Crop button just
// doesn't render and the apply/cancel face is unreachable.
cropping = false,
onStartCrop,
onApplyCrop,
onCancelCrop,
// Undo crop — reverts the most recent crop operation on the
// element. Sibling of Crop in the action row, but only shown when
// there's a crop to undo. Behaviour lives in App.jsx's
// `handleUndoCrop`: it restores the pre-crop dimensions AND the
// previous crop value (so multi-crop sequences walk back one step
// rather than jumping to fully-uncropped), with a fallback to
// fit-to-bounds when the user resized between the crop and the
// click. Wiring is optional — when the callback isn't supplied,
// the button just doesn't render.
onUndoCrop,
// Mobile-only layer ops. Desktop users access these via the
// LayersPanel in the right-rail sidebar (per-row Duplicate / Lock
// / Up / Down / Delete buttons there); on mobile that panel sits
// inside the bottom sheet, which is several taps away from the
// canvas. Surfacing the same operations on the floating mobile
// toolbar gives the user immediate access to layer lifecycle and
// z-order ops without ever opening the sheet.
//
// All four are wired only for the mobile instance in App.jsx —
// the desktop instance leaves them undefined and the section
// doesn't render. Each is element-shaped (receives the full
// selected element) so the handler in App can read `.id` plus
// any other state (e.g. current `locked` flag if needed) without
// a second selector lookup.
onDuplicate,
onDelete,
onBringForward,
onSendBackward,
}) {
// Anchor the slider's "100%" to the *current* element dimensions when it
// first renders. Without this, repeatedly dragging the slider in opposite
// directions would compound floating-point error and slowly shrink the
// element. We capture the anchor lazily — once per element id — via useMemo.
const sizeAnchor = useMemo(() => {
if (!element) return null;
if (element.type === 'image' || element.type === 'sticker') {
return { width: element.width || 100, height: element.height || 100 };
}
if (element.type === 'text') {
return { fontSize: element.fontSize || 32 };
}
return null;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [element?.id]);
// Per-drag-session center reference (image/sticker only).
//
// Why this exists: the size slider on mobile can fire many onChange
// events per second during a drag. Each event recomputes the
// element's new x/y from the CURRENT element state's center
// (`element.x + element.width / 2`). That's correct in isolation —
// each frame preserves the center it observes — but the round-to-
// integer math has a subtle bias: JS's `Math.round` rounds half-
// values AWAY FROM ZERO for positive numbers, so any input like
// X.5 systematically becomes X+1. There's no compensating round-
// down path, so over many up-down cycles the per-frame bias
// accumulates in one direction (toward +x, +y for positive
// coordinates) and the element visibly drifts toward the bottom-
// right. With small ratios (e.g. anchor 158×240, slider at pct=103
// → odd widths/heights produce X.5 half-widths) a few up-down
// cycles can shift the element 20-30px.
//
// The fix is to NOT recompute the center from compounding frame
// state during a continuous drag. Instead, capture the center ONCE
// at the start of the drag session and reuse it for every frame
// until release. No chain of compounding rounds; the center is
// re-anchored on each fresh drag (so user moves between drags
// don't cause snap-back).
//
// Session start is detected by checking whether the ref is null
// at the top of a slider onChange call — it's cleared by the
// commit handler (pointerup / touchend). The ref carries the
// captured (centerX, centerY) plus the element id so a selection
// change between drag-start and drag-end doesn't accidentally
// reuse a stale center for the new element.
const sliderCenterRef = useRef(null);
if (!element) return null;
// Crop-mode UI — replaces everything else. Two prominent buttons so
// the user can't miss the resolution of the in-progress crop. Apply
// is the primary action (filled pink), Cancel is secondary (outlined).
// Title bar keeps the user oriented in what is effectively a modal
// state on the toolbar surface.
if (cropping) {
return (
Crop image
);
}
const opacity = element.opacity ?? 1;
const brightness = element.brightness ?? 0;
const contrast = element.contrast ?? 0;
const rotation = element.rotation ?? 0;
// Rotation slider range (mobile only). ±90° covers half a turn,
// which is the band where rotation-as-fine-tuning is most useful;
// beyond that the user typically wants the on-canvas rotate handle
// which can swing through the full 360°. Same legacy-aware
// clamping pattern as the brightness/contrast sliders: an element
// that's been rotated to e.g. 180° via the rotate handle will
// show the slider visually pegged at ±90 (whichever endpoint is
// closer) and the readout matches, but element.rotation in state
// stays at 180° until the user drags the slider. This means the
// first drag interaction will jump rotation to whatever the
// slider says — acceptable, because the slider IS the user's
// explicit "I want this rotation" gesture at that point.
const ROTATION_SLIDER_MAX = 90;
const rotationDisplay = Math.min(
ROTATION_SLIDER_MAX,
Math.max(-ROTATION_SLIDER_MAX, Math.round(rotation)),
);
// Brightness/contrast slider range. Originally ±100 (the full
// range Konva's filters accept), but in practice values beyond
// ±20 weren't producing useful adjustments — they just blew out
// the image. Narrowing the slider to ±20 gives finer control in
// the band where the adjustments actually read as photo edits
// rather than "that's broken" filter abuse. The underlying state
// still uses Konva's native scale (-1..+1 for brightness, ±100
// for contrast); we just don't expose the extremes through the
// slider.
//
// For legacy elements whose saved values still exceed ±20 (from
// designs created before this narrowing), clamp both the slider
// position AND the displayed integer so the UI stays consistent.
// The underlying state is left alone until the user actually
// interacts with the slider; on first onChange the value commits
// within the new range and the legacy out-of-range value is
// overwritten. Clamping the DISPLAY (not just the slider) avoids
// the awkward case where a legacy brightness=0.5 element would
// show the slider pegged at 20 but the numeric readout saying
// "50" — looks like a bug, even though it's technically the
// true state.
const BC_SLIDER_MAX = 20;
const brightnessDisplay = Math.min(
BC_SLIDER_MAX,
Math.max(-BC_SLIDER_MAX, Math.round(brightness * 100)),
);
const contrastDisplay = Math.min(
BC_SLIDER_MAX,
Math.max(-BC_SLIDER_MAX, Math.round(contrast)),
);
const sizePct = (() => {
if (!sizeAnchor) return 100;
if ((element.type === 'image' || element.type === 'sticker')
&& element.width && sizeAnchor.width) {
return Math.round((element.width / sizeAnchor.width) * 100);
}
if (element.type === 'text' && element.fontSize && sizeAnchor.fontSize) {
return Math.round((element.fontSize / sizeAnchor.fontSize) * 100);
}
return 100;
})();
const handleSizeChange = (pct) => {
if (!sizeAnchor) return;
const ratio = pct / 100;
if (element.type === 'image' || element.type === 'sticker') {
// Center-preserving scale.
//
// The slider's anchor is captured once per element id and the
// raw target dimensions are computed from it — same as before.
// What's new is that we ALSO emit x/y so the element's visual
// center stays fixed across the resize. Without this, raw
// width/height updates anchor at the element's top-left (x/y
// are unchanged), so dragging the slider up makes the element
// appear to grow rightward and downward; dragging down shrinks
// toward the top-left. Users consistently expect a size slider
// to scale from the center the way the rotate handle pivots
// around the center.
//
// Math: current center is (x + W/2, y + H/2) — the same point
// that's the rotation pivot (offset is always-center, see the
// docblock in ImageElement.jsx). We keep that center fixed by
// computing newX = center.x - newW/2, newY = center.y - newH/2.
// Works identically for rotated and unrotated elements: the
// center point is rotation-invariant under the center-offset
// convention.
//
// Per-frame behavior during a continuous drag: the center is
// captured ONCE at the start of the drag session
// (sliderCenterRef.current is null → capture it) and reused
// for every subsequent frame in that session. Using a fixed
// captured center, rather than recomputing from each frame's
// committed state, avoids cumulative drift from Math.round's
// round-half-away-from-zero bias: each per-frame computation
// is independent of the previous frame's rounded result, so
// there's no chain of X.5 → X+1 promotions accumulating in a
// single direction. The captured center is cleared on commit
// (pointerup / touchend), so the next drag session captures
// afresh — picking up any position changes the user made
// between sessions (e.g. dragging the element on canvas
// between two slider sessions).
//
// Floor at 20px matches the existing MIN_ELEMENT_SIZE
// convention; rounding to integers matches the rest of the
// codebase (handleApplyCrop, handlePhotoEditComplete, etc.)
// — sub-pixel x/y values don't render visibly differently and
// clutter the state shape unnecessarily.
const newWidth = Math.max(20, Math.round(sizeAnchor.width * ratio));
const newHeight = Math.max(20, Math.round(sizeAnchor.height * ratio));
// Capture the center on the first frame of a new drag session.
// The element-id check guards against a selection change mid-
// session (the previous element's center would be stale for
// the new selection — reset and recapture).
const capturedSession = sliderCenterRef.current;
let centerX, centerY;
if (
capturedSession
&& capturedSession.elementId === element.id
) {
centerX = capturedSession.centerX;
centerY = capturedSession.centerY;
} else {
const currentWidth = element.width ?? sizeAnchor.width;
const currentHeight = element.height ?? sizeAnchor.height;
centerX = (element.x ?? 0) + currentWidth / 2;
centerY = (element.y ?? 0) + currentHeight / 2;
sliderCenterRef.current = {
elementId: element.id,
centerX,
centerY,
};
}
onUpdate({
x: Math.round(centerX - newWidth / 2),
y: Math.round(centerY - newHeight / 2),
width: newWidth,
height: newHeight,
});
} else if (element.type === 'text') {
// Text doesn't need explicit center-preservation math here —
// the App-level updateSelectedElement wrapper runs
// withTextCenterPreservation on every text patch, and that
// helper detects a fontSize change and injects matching x/y
// that preserve the rendered bbox's visual center. So sending
// just { fontSize } from here is equivalent to the explicit
// x/y math above — without duplicating the text-width
// measurement logic (which depends on font metrics and lives
// in textGeometry.js).
onUpdate({ fontSize: Math.max(12, Math.round(sizeAnchor.fontSize * ratio)) });
}
};
// Commit handler for the size slider. Released on pointerup /
// touchend, it pushes the in-progress state to history (so undo
// collapses the whole drag into one entry) AND clears the captured-
// center ref so the next drag session re-anchors against the current
// element state. Wrapping onCommit + ref-clear together ensures the
// ref doesn't leak past the gesture boundary in any release path.
const handleSizeCommit = () => {
sliderCenterRef.current = null;
onCommit?.();
};
// Per-slider "is this changed from default" flags, used to
// conditionally render a small reset button on the right of each
// slider row. The float tolerances on opacity / brightness /
// contrast guard against floating-point noise creeping in via the
// slider's /100 division — without tolerance, dragging a slider
// back to 0 (or 100% for opacity) could leave a residual 0.000003
// that keeps the reset button stuck visible. Size uses an exact
// check because sizePct is already rounded to an int above.
//
// "Default" semantics per slider:
// • Opacity → 1 (fully opaque)
// • Size → 100% of the sizeAnchor captured at first
// render. Reset = handleSizeChange(100), which
// restores the element to the dimensions it
// had when the toolbar first mounted for it.
// Not "original-at-upload-time" if the user
// resized via canvas drag before opening the
// toolbar — the anchor is the toolbar-mount
// reference, same as the slider's 100% point.
// • Brightness → 0 (no shift). Detection compares against the
// underlying state, not the clamped display, so
// legacy brightness=0.5 elements still surface
// the reset button even though the slider is
// pegged at the new ±20 limit.
// • Contrast → 0 (no shift). Same legacy-aware detection as
// brightness.
// • Rotation → 0 (no rotation). Detected against the raw
// underlying state for the same reason as
// brightness/contrast — a legacy element
// rotated past ±90 still surfaces the reset.
const opacityChanged = Math.abs(opacity - 1) > 0.001;
const sizeChanged = sizePct !== 100;
const brightnessChanged = Math.abs(brightness) > 0.001;
const contrastChanged = Math.abs(contrast) > 0.5;
const rotationChanged = Math.abs(rotation) > 0.5;
const isText = element.type === 'text';
const isImage = element.type === 'image';
const isSticker = element.type === 'sticker';
const allowFlip = isImage;
const allowCrop = isImage && !!onStartCrop;
// Undo-crop is only meaningful when there's an existing crop to
// revert. element.crop is set by handleApplyCrop in App.jsx to a
// {sx, sy, sWidth, sHeight} object; absent (or undefined) means
// the image is rendering its full natural pixels and there's
// nothing for "undo" to do.
const allowUndoCrop = isImage && !!element.crop && !!onUndoCrop;
// Photo surfaces (filters, background removal, brightness/contrast)
// are image-only.
const isPhoto = isImage;
// Sliders apply to any element with a size dimension.
const hasSlidableSize = isText || isImage || isSticker;
return (
{isText && isMobile && onEditText && (
onEditText(element)}>
Edit text
)}
{allowFlip && (
onUpdate({ flipX: !element.flipX })}>
Flip H
)}
{allowFlip && (
onUpdate({ flipY: !element.flipY })}>
Flip V
)}
{/* Crop button — opens on-canvas crop UI. Image elements only.
Sits next to Flip H/V because conceptually they're all
"geometry of the rendered image" controls. */}
{allowCrop && (
onStartCrop(element)}>
Crop
)}
{/* Undo crop — only visible when the element has an existing
crop applied. Reverts the most recent crop operation:
restores the pre-crop dimensions and (for multi-crop
sequences) the prior crop value, so the user walks back
one step rather than unwinding all the way to uncropped.
Targeted alternative to Cmd+Z, which would also undo any
unrelated edits made after the crop. */}
{allowUndoCrop && (
onUndoCrop(element)}>
Undo crop
)}
{/* Remove Background — image-only. Moved here from the bottom
"advanced" section so the user can reach it at the same
visual level as Flip and Crop. The compact variant renders
as a `.el-toolbar__btn` matching the buttons around it; the
wider pill was replaced after brightness/contrast/crop
became reachable on-canvas, since the previous "advanced"
section is now sparse enough that a single dominant pill
looked out of place. */}
{isPhoto && (
onUpdate(attrs)}
variant="compact"
/>
)}
{/* Layer controls — MOBILE ONLY.
*
* Desktop users have the LayersPanel in the right-rail sidebar
* with per-row Duplicate / Lock / Up / Down / Delete buttons,
* and keyboard shortcuts (Cmd+D, [, ], Backspace) covering the
* same ground. On mobile, the LayersPanel lives inside the
* bottom sheet — several taps away from the canvas and not
* visible while the user is working with the selected element.
* Surfacing the same operations as a labelled section on the
* floating toolbar removes that round trip.
*
* Section is gated on `isMobile` so it's invisible on desktop
* (where it would be redundant with the sidebar layers panel)
* and per-button on each handler's presence so a caller that
* forgets to wire a single op gets a quietly-shorter row
* rather than a broken click. Lock is always present because
* it uses the always-wired onUpdate path — same pattern as
* the Flip H / Flip V buttons in the action row above. */}
{isMobile && (
Layer controls
{onDuplicate && (
onDuplicate(element)}>
Duplicate
)}
{/* Lock toggle. Uses the existing onUpdate/onCommit path
* rather than a dedicated handler — same pattern as the
* Flip H / Flip V buttons in the action row above. Icon
* mirrors the CURRENT state (closed lock when locked,
* open lock when unlocked) and the label reads as the
* action the click will perform, so the affordance is
* "I am [state], click to [other state]". */}
{ onUpdate({ locked: !element.locked }); onCommit?.(); }}
>
{element.locked ? 'Unlock' : 'Lock'}
{onBringForward && (
onBringForward(element)}>
Above
)}
{onSendBackward && (
onSendBackward(element)}>
Below
)}
{onDelete && (
onDelete(element)}>
{/* Trash glyph — same shape used by the UploadTab's
per-thumb delete and the LayersPanel row delete,
so users see one consistent "delete" affordance
across the editor. */}
Delete
)}
{/* Rotation — MOBILE ONLY.
*
* Desktop users have the on-canvas rotate handle on the
* Konva Transformer, plus shift-to-snap-to-15° for
* keyboard-precision rotation. On mobile the rotate handle
* sits above the bbox in a position that competes with the
* top-of-screen toolbar at smaller element sizes; this
* slider is the alternative entry point for rotation
* without needing pixel-precise touch on the handle.
*
* Range is ±90° (see ROTATION_SLIDER_MAX docblock). For
* larger rotations the user falls back to the canvas
* handle. Same per-slider grid + reset pattern as the
* other rows.
*
* Gated on isMobile so the desktop floating toolbar stays
* unchanged — desktop rotation lives entirely on the
* canvas surface. The same row would visually crowd the
* already-busy desktop toolbar with a control desktop
* users don't need. */}
{isMobile && (
)}
{/* Brightness + Contrast — image-only, ride the same slider
grid as Opacity/Size for visual consistency. Slider range
is ±20 (narrowed from Konva's native ±100 / ±1 ranges
because values beyond ~20 stopped reading as photo edits
and started looking like filter abuse). Underlying state
still uses Konva's scale: brightness ÷ 100 to land in
-1..+1, contrast direct. The displayed integer is
clamped via BC_SLIDER_MAX so legacy elements with
out-of-range saved values render with the slider and
readout in agreement — the underlying state isn't
touched until the user actually drags. */}
{isPhoto && (
)}
{/* Filter chips — image-only AND desktop-only. The "advanced"
section used to also hold the Edit Photo wide pill and the
wide Background Removal pill; both are gone now (Edit Photo
replaced by on-canvas tools, BG removal moved to the top row
in compact form), so this section is just the filter chips.
Kept as its own row with the dashed top border for visual
grouping.
Hidden on mobile per user feedback: the four presets
(original, B&W, sepia, warm) weren't seeing meaningful use,
and the section's vertical footprint was eating space on
the floating mobile toolbar that's better spent on the
more-used controls (layer ops, sliders). Slight degradation
in mobile functionality — mobile users can still apply
filters from the bottom-sheet sidebar if they need them —
accepted in exchange for a tighter floating toolbar.
The dashed top border lives on `.el-toolbar__advanced`
itself, so hiding the section also removes its separator,
and the sliders above end without a trailing rule on
mobile (cleaner endpoint for the toolbar). */}
{isPhoto && !isMobile && (
);
}
function ToolbarButton({ children, onClick, label, variant }) {
return (
);
}
/**
* Small icon-only button rendered in the 4th grid column of a slider
* row when the slider's value differs from its default. Single-tap
* resets that slider to default (1 for opacity, 100% of anchor for
* size, 0 for brightness/contrast). The caller passes both the
* click handler and the aria/title label — we don't infer the
* action label from context here because it needs to read
* meaningfully to screen readers ("Reset opacity", "Reset brightness")
* rather than a generic "Reset".
*
* Icon is the rotate-ccw glyph (counter-clockwise circular arrow
* with a tail-arrowhead at the top-left), the conventional "reset
* to default" / "undo state" visual in design tools. Distinct from
* the toolbar's Undo Crop icon (which is a different shape) so the
* two don't blur together: Undo Crop reverts one crop op on the
* whole element, while this reset reverts a single slider on the
* selected element.
*
* 14×14 SVG is small enough to fit comfortably in the 24px-wide
* reset column on the slider row, with the button's currentColor
* inheriting the muted secondary text color until hover.
*/
function SliderResetButton({ onClick, label }) {
return (
);
}
/**
* Tiny informational icon rendered flanking the slider track on each
* end. Indicates direction ("this end of the slider produces less of
* the thing; the other end produces more") so the user can read the
* control's behavior at a glance without having to drag it first.
*
* Five `kind` values, each with start (left/min) and end (right/max)
* variants:
*
* • opacity — dim square → solid square
* • size — small square → large square
* • rotation — ccw curved arrow → cw curved arrow
* • brightness — sun with few rays → sun with many rays
* • contrast — soft-split half-circle → hard-split half-circle
*
* Styling notes:
* • Not interactive (no click handler, no hover, no focus ring,
* no cursor change). Pure visual hint, like a slider tick mark.
* `aria-hidden="true"` plus no tabindex keeps them out of the
* focus order; screen readers get the meaning from the slider
* row's label ("Opacity", "Size", etc.) which already conveys
* what the control does. Adding redundant aria descriptions for
* the end icons would just make the AT readout chatty without
* adding information.
* • Same 14×14 SVG size as the reset button, sharing the same
* muted text-secondary color, so the slider row's right-hand
* icons (reset) and inner icons (end hints) read as part of
* the same visual family.
* • The CSS class `.el-toolbar__slider-end-icon` carries the
* layout + sizing rules. The `--start` and `--end` modifier
* classes are present in case future styling needs to
* distinguish them (e.g. mirroring an asymmetric glyph), but
* today they share the same rules.
*/
function SliderEndIcon({ kind, position }) {
const className = `el-toolbar__slider-end-icon el-toolbar__slider-end-icon--${position}`;
const svgProps = {
viewBox: '0 0 24 24',
width: 14,
height: 14,
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
'aria-hidden': true,
};
if (kind === 'opacity') {
// Two filled circles of identical geometric size, differing only
// in fill opacity. The start (less opaque) variant is currentColor
// at ~30% opacity — reads as a faded version of the same shape;
// the end (fully opaque) variant is currentColor at 100%. The
// icon itself IS its own legend: the user sees a faded dot and a
// solid dot, immediately matching the slider's behavior.
//
// No stroke on either version. Earlier iterations used a dashed
// stroke on the start variant and a solid fill on the end
// variant, but (a) dashed-outline vs solid-fill compared two
// different visual languages rather than two states of the same
// shape, making the relationship less clear; and (b) the
// stroked circle ended up visually LARGER than the filled
// circle, because a stroke sits half outside the geometric
// radius — a `r=7` stroked circle spans ~16px diameter (14 + 2
// for the stroke), while a `r=7` filled circle spans 14px
// diameter. Dropping the stroke on both makes the two circles
// identical except for opacity, which is exactly the visual
// contract the slider promises.
//
// currentColor at 0.3 on the start variant means the icon
// inherits the muted text-secondary color set on the end-icon
// container, then attenuates it to ~30% — enough to read as
// "present but faded" without disappearing into the toolbar
// background. The end variant gets currentColor at 1.0 (the
// default), so it lands at the same muted color as the rest of
// the slider end icons.
return position === 'start' ? (
) : (
);
}
if (kind === 'size') {
// Small circle (start = smaller) vs large circle (end = larger).
// Circle outlines (not filled) so the icon doesn't read as a solid
// dot at small sizes — the user's attention should be on the
// size comparison, not the contrast against the toolbar background.
// Matching the circle vocabulary used by opacity / brightness /
// contrast in this same row.
return position === 'start' ? (
) : (
);
}
if (kind === 'rotation') {
// Curved arrows showing rotation direction. The slider's left end
// (start) is the smaller numerical value and the right end (end)
// is the larger one, but rotation icons are about *direction* not
// magnitude — so we pair each side with the rotation sense the
// user expects to see when dragging toward that end.
//
// Empirically: the user reads the left icon as "this end rotates
// it this way" and expects the curved arrow's direction to match
// their mental model of which way the element will spin. The
// previous mapping had these swapped (left showed CCW, right
// showed CW); switching to left=CW / right=CCW matches what the
// user expects.
//
// The SVG paths themselves are unchanged — only which side gets
// which path swapped. Tail-arrow positions encode direction:
// • "M3 12a9 9 0 1 0 9-9 ..." + "M3 3v5h5" — arc traveled
// counter-clockwise from the start point, tail at top-left.
// • "M21 12a9 9 0 1 1-9-9 ..." + "M21 3v5h-5" — arc traveled
// clockwise from the start point, tail at top-right.
return position === 'start' ? (
) : (
);
}
if (kind === 'brightness') {
// Sun glyphs with different ray densities. Start (less bright) =
// sun with 4 rays at cardinal positions; end (more bright) = sun
// with 8 rays. Same center circle radius for both so the
// comparison reads as "the same sun, brighter or dimmer" rather
// than "two different suns."
return position === 'start' ? (
) : (
);
}
if (kind === 'contrast') {
// Half-filled circle, the standard contrast glyph. Start (less
// contrast) shows a thin diagonal stripe shaded inside the circle,
// suggesting a softer dark/light split; end (more contrast) shows
// the full half-filled circle with a sharp vertical split between
// the dark and light halves. Both share the same outer circle so
// they read as the same "contrast indicator," different intensities.
return position === 'start' ? (
) : (
);
}
return null;
}