@kolkrabbi/kol-component 0.195.0 → 0.196.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.195.0",
3
+ "version": "0.196.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,6 +20,12 @@ import { glyphSize } from '../hooks/glyphLadders.js'
20
20
  * scope expression, not every keystroke. With it
21
21
  * the field keeps a local draft seeded from
22
22
  * `value`; `onChange` still fires live if given.
23
+ * After the commit the draft RE-SNAPS to `value`,
24
+ * so a rejected commit falls back to the last
25
+ * good value rather than lingering — which makes
26
+ * `type="number"` + `onCommit` the draft/commit
27
+ * number idiom outright (parse and clamp at the
28
+ * call site; fxr's NumberField, retired 2026-09-03).
23
29
  * variant="ghost" — legacy alias, resolves to outline
24
30
  * variant="property" — the Figma property field (PropertyField,
25
31
  * 2026-08-12): filled chrome, dim `affordance`
@@ -123,7 +129,17 @@ export default function Input({
123
129
  ? {
124
130
  value: draft,
125
131
  onChange: (e) => { draftRef.current = e.target.value; setDraft(e.target.value); onChange?.(e) },
126
- onBlur: () => onCommit(String(draftRef.current).trim()),
132
+ /* RE-SNAP AFTER EVERY COMMIT, not only when `value` changes. The
133
+ * effect above re-syncs the draft on a value change — so a commit the
134
+ * caller REJECTED (invalid input, value kept) left the bad draft on
135
+ * screen, and `1` → `19` → `19x` showed `19x` after blur. kol-fxr's
136
+ * `NumberField` existed for exactly this line (34 lines wrapping this
137
+ * atom: commit, then `setDraft(String(value))`); with the re-snap here
138
+ * it is `<Input type="number" onCommit>` and no component
139
+ * (editor-panels-the-held-specs A8, 2026-09-03). The order matters —
140
+ * commit first, so a caller that DOES accept the value re-renders
141
+ * with the new prop and the effect wins over this fallback. */
142
+ onBlur: () => { onCommit(String(draftRef.current).trim()); draftRef.current = value ?? ''; setDraft(value ?? '') },
127
143
  onKeyDown: (e) => {
128
144
  if (e.key === 'Enter') e.currentTarget.blur()
129
145
  if (e.key === 'Escape') { draftRef.current = value ?? ''; setDraft(value ?? ''); e.currentTarget.blur() }
@@ -0,0 +1,92 @@
1
+ import { useRef } from 'react'
2
+
3
+ /**
4
+ * XYPad — a two-axis control pad: drag one puck to vary two values at once.
5
+ *
6
+ * Lifted verbatim from kol-fxr's editor (`compose/inspectors/XYPad.jsx`,
7
+ * `editor-panels-the-held-specs` A6, 2026-09-03 — the row the filer marked
8
+ * "portable as-is", and it was: presentation-only, no store coupling). Its own
9
+ * lineage runs back through kol-labs-single's para-type lab to Font Playground.
10
+ *
11
+ * Axis meaning, ranges and the write path belong to the caller. It fills its
12
+ * container's width and is square via `aspect-ratio`; the puck is positioned
13
+ * in %, so there is no `size` prop and a rail of any width takes it. `y` is
14
+ * inverted — top is high — because that is how every axis pad reads.
15
+ *
16
+ * No `useCallback` on the handlers, on purpose: the labs original memoized
17
+ * them with `[]` deps and froze the first render's axis ranges into the drag
18
+ * math, so a pad whose range changed kept mapping to the old one.
19
+ *
20
+ * <XYPad xValue={wdth} yValue={wght} xMin={50} xMax={200} yMin={100} yMax={900}
21
+ * xLabel="Width" yLabel="Weight" onChange={(x, y) => set({ wdth: x, wght: y })} />
22
+ *
23
+ * @param {number} xValue - Current x, in the x range
24
+ * @param {number} yValue - Current y, in the y range
25
+ * @param {number} [xMin=0] - x at the left edge
26
+ * @param {number} [xMax=1] - x at the right edge
27
+ * @param {number} [yMin=0] - y at the BOTTOM edge
28
+ * @param {number} [yMax=1] - y at the top edge
29
+ * @param {Function} onChange - `(x, y) => void` on pointer down and on every move while a button is held — the caller coalesces if it wants one patch per gesture
30
+ * @param {ReactNode} xLabel - Left label above the pad
31
+ * @param {ReactNode} yLabel - Right label above the pad
32
+ * @param {string} [className] - Extra classes on the wrapper
33
+ */
34
+ export default function XYPad({
35
+ xValue, yValue,
36
+ xMin = 0, xMax = 1,
37
+ yMin = 0, yMax = 1,
38
+ onChange,
39
+ xLabel,
40
+ yLabel,
41
+ className = '',
42
+ }) {
43
+ const ref = useRef(null)
44
+
45
+ const handlePos = (e) => {
46
+ const el = ref.current
47
+ if (!el) return
48
+ const rect = el.getBoundingClientRect()
49
+ const px = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
50
+ const py = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
51
+ const x = xMin + px * (xMax - xMin)
52
+ const y = yMax - py * (yMax - yMin) /* invert: top = high */
53
+ onChange?.(x, y)
54
+ }
55
+
56
+ const onPointerDown = (e) => {
57
+ e.target.setPointerCapture?.(e.pointerId)
58
+ handlePos(e)
59
+ }
60
+ const onPointerMove = (e) => {
61
+ if (e.buttons === 0) return
62
+ handlePos(e)
63
+ }
64
+
65
+ const span = (max, min) => (max - min) || 1
66
+ const puckX = ((xValue - xMin) / span(xMax, xMin)) * 100
67
+ const puckY = (1 - (yValue - yMin) / span(yMax, yMin)) * 100
68
+
69
+ return (
70
+ <div className={`flex flex-col gap-1 ${className}`.trim()}>
71
+ <div className="flex justify-between kol-helper-10 tracking-widest text-meta">
72
+ <span>{xLabel}</span>
73
+ <span>{yLabel}</span>
74
+ </div>
75
+ <div
76
+ ref={ref}
77
+ onPointerDown={onPointerDown}
78
+ onPointerMove={onPointerMove}
79
+ className="relative w-full aspect-square border border-fg-16 bg-fg-04 rounded cursor-crosshair touch-none"
80
+ >
81
+ {/* crosshair guides */}
82
+ <div className="absolute inset-x-0 top-1/2 border-t border-fg-08" />
83
+ <div className="absolute inset-y-0 left-1/2 border-l border-fg-08" />
84
+ {/* puck */}
85
+ <div
86
+ className="absolute w-3 h-3 -ml-1.5 -mt-1.5 rounded-full bg-fg-96 border border-fg-04 pointer-events-none"
87
+ style={{ left: `${puckX}%`, top: `${puckY}%` }}
88
+ />
89
+ </div>
90
+ </div>
91
+ )
92
+ }
package/src/index.js CHANGED
@@ -65,6 +65,9 @@ export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
65
65
  export { default as TiltCard } from './utilities/TiltCard.jsx'
66
66
  export { default as TransparentX } from './utilities/TransparentX.jsx'
67
67
  export { default as ViewToggle } from './atoms/ViewToggle.jsx'
68
+ /* the design-editor parts, taken per row from editor-panels-the-held-specs (2026-09-03) */
69
+ export { default as XYPad } from './atoms/XYPad.jsx'
70
+ export { default as InspectorRail } from './molecules/InspectorRail.jsx'
68
71
 
69
72
  // molecules
70
73
  export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
@@ -0,0 +1,59 @@
1
+ /**
2
+ * InspectorRail — the selection-routing shell of an inspector panel.
3
+ *
4
+ * Lifted from kol-fxr's editor (`compose/InspectorRail.jsx`,
5
+ * `editor-panels-the-held-specs` A7, 2026-09-03) with ALL of its coupling
6
+ * dropped: it read the compose store and imported three concrete panels. What
7
+ * is left is the one piece of logic the filer said everyone gets wrong — the
8
+ * precedence — and it is the thing that makes an inspector rail a component
9
+ * rather than a `<div>`:
10
+ *
11
+ * nothing selected → renders NOTHING (user ruling 2026-08-12: no dummy
12
+ * empty-state copy; selection must visibly spawn
13
+ * its controls)
14
+ * the canvas is selected → `renderers.canvas`, and it WINS over multi-select.
15
+ * Selecting the canvas selects every layer with it,
16
+ * so without this precedence "inspect the canvas"
17
+ * with two layers in frame fell into the multi
18
+ * branch and hid the fill / opacity controls
19
+ * exactly one id → `renderers.single(id)`
20
+ * two or more ids → `renderers.multi(ids)` — the canvas id excluded
21
+ * from the count; it is selectable but not a layer
22
+ *
23
+ * The panels themselves are the consumer's — a layer inspector delegating by
24
+ * type, a canvas inspector, a multi-select summary with a Group action — and
25
+ * they arrive as render functions so this file imports none of them.
26
+ *
27
+ * <InspectorRail
28
+ * selectedIds={selectedIds}
29
+ * canvasId="canvas"
30
+ * renderers={{
31
+ * canvas: () => <CanvasInspector />,
32
+ * single: (id) => <LayerInspector layer={find(id)} />,
33
+ * multi: (ids) => <MultiSummary ids={ids} onGroup={group} />,
34
+ * }}
35
+ * />
36
+ *
37
+ * @param {string[]} selectedIds - The current selection, in selection order; may include `canvasId`
38
+ * @param {string} [canvasId='canvas'] - The id that means "the canvas itself" — precedence, and excluded from the multi count
39
+ * @param {{canvas?: Function, single?: Function, multi?: Function}} renderers - `canvas()`, `single(id)`, `multi(ids)` — each returns the node for that state; a missing renderer renders nothing for it
40
+ * @param {string} [className] - Extra classes on the rail
41
+ */
42
+ export default function InspectorRail({ selectedIds = [], canvasId = 'canvas', renderers = {}, className = '' }) {
43
+ const isCanvas = selectedIds.includes(canvasId)
44
+ const layerIds = selectedIds.filter((id) => id !== canvasId)
45
+
46
+ const body = isCanvas
47
+ ? renderers.canvas?.()
48
+ : layerIds.length >= 2
49
+ ? renderers.multi?.(layerIds)
50
+ : layerIds.length === 1
51
+ ? renderers.single?.(layerIds[0])
52
+ : null
53
+
54
+ return (
55
+ <div className={`kol-inspector-rail ${className}`.trim()}>
56
+ {body && <div className="kol-inspector-rail-body">{body}</div>}
57
+ </div>
58
+ )
59
+ }