diff --git a/bun.lock b/bun.lock index 1408122b1b..d2c4cda4a3 100644 --- a/bun.lock +++ b/bun.lock @@ -835,6 +835,7 @@ "@solid-primitives/resize-observer": "2.1.3", "@solidjs/meta": "catalog:", "@solidjs/router": "catalog:", + "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", "dompurify": "3.3.1", "fuzzysort": "catalog:", diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index d1fbf5b9aa..6c8ad34074 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -511,12 +511,6 @@ function HomeDesign() { }) .catch(() => {}) } - const forkWidget = (id: string) => { - void amicodePost(focusedServer(), "/amicode/widget-fork", { id }) - .then(() => void refetchWidgets()) - .catch(() => {}) - } - const WIZARD_DISMISS_KEY = "amicode-onboarding-dismissed" const [wizardOpen, setWizardOpen] = createSignal(false) let wizardDecided = false @@ -1021,7 +1015,13 @@ function HomeDesign() { context={widgetContext()} callbacks={widgetCallbacks} onSave={saveDashboard} - onFork={forkWidget} + onNewWidget={() => + // Authoring is a conversation: hand off to a fresh chat with the + // composer prefilled (not auto-sent — the user completes the + // sentence). amicode_author_widget takes it from there and the + // preview card's "Pin to dashboard" closes the loop. + startWithPrompt("I want to create a new widget for my home dashboard. It should show ") + } /> )} diff --git a/packages/ui/package.json b/packages/ui/package.json index 06776cfa12..7e2565c875 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -54,6 +54,7 @@ "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", "@solid-primitives/bounds": "0.1.3", + "@thisbeyond/solid-dnd": "0.7.5", "@solid-primitives/event-listener": "2.4.5", "@solid-primitives/media": "2.3.3", "@solid-primitives/resize-observer": "2.1.3", diff --git a/packages/ui/src/amicode/amicode.css b/packages/ui/src/amicode/amicode.css index 156fbae268..6a2aa29739 100644 --- a/packages/ui/src/amicode/amicode.css +++ b/packages/ui/src/amicode/amicode.css @@ -778,13 +778,7 @@ position: absolute; inset: 0; z-index: 1; border-radius: 10px; cursor: default; background: color-mix(in srgb, var(--v2-background-bg-base) 44%, transparent); } -.amc-wg-name { - position: absolute; top: 9px; left: 11px; z-index: 2; pointer-events: none; - font-size: 11px; font-weight: 600; color: var(--v2-text-text-base); - text-shadow: 0 1px 4px color-mix(in srgb, var(--v2-background-bg-base) 85%, transparent); - max-width: 55%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} -.amc-wg-name .amc-wg-empty { color: var(--v2-text-text-faint); font-weight: 500; margin-left: 4px; } +.amc-wg-placeholder .amc-wg-empty { color: var(--v2-text-text-faint); font-weight: 500; margin-left: 4px; } .amc-wg-pill { position: absolute; top: 7px; right: 7px; z-index: 2; display: flex; align-items: center; gap: 1px; padding: 3px; @@ -803,8 +797,6 @@ } .amc-wg-pill button:disabled { opacity: 0.28; cursor: default; } .amc-wg-pill .amc-wg-danger:hover:not(:disabled) { color: var(--v2-state-fg-danger); } -.amc-wg-pill .amc-wg-fork { font-size: 10px; letter-spacing: 0.02em; color: var(--v2-text-text-faint); } -.amc-wg-pill .amc-wg-sep { width: 1px; height: 14px; background: var(--v2-border-border-base); margin: 0 3px; flex: 0 0 auto; } [data-component="amicode-widget-editbar"] { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; @@ -994,3 +986,23 @@ background: var(--v2-border-border-base); margin: 4px 2px; } + +/* ---- widget grid: drag reorder + Add-widget menu (widget UX PR) ---------- + * Move mode: the whole scrimmed card is the drag handle. No overlay ghost, no + * fade (Kate: widgets must look the SAME while dragging) — the card itself + * follows the pointer via the sortable transform, riding above its neighbors. + * Reduced motion kills the slide transitions. */ +.amc-wg-sortable { touch-action: none; min-width: 0; } +.amc-wg-sortable.amc-wg-active { position: relative; z-index: 5; } +.amc-wg-sortable:focus-visible { outline: 1px solid var(--v2-border-border-strong); outline-offset: 2px; border-radius: 10px; } +[data-component="amicode-widget-cell"][data-editing="true"] .amc-wg-scrim { cursor: grab; } +.amc-wg-sortable.amc-wg-active .amc-wg-scrim { cursor: grabbing; } +@media (prefers-reduced-motion: reduce) { + .amc-wg-sortable { transition: none !important; } +} +/* empty board (nothing visible) — quiet entry back into Add widget */ +.amc-wg-empty-actions { display: flex; align-items: center; gap: 12px; font-size: 11px; color: var(--v2-text-text-faint); } +.amc-wg-empty-actions button { border: none; background: transparent; padding: 0; font-size: 11px; + color: var(--v2-text-text-muted); cursor: pointer; } +.amc-wg-empty-actions button:hover { color: var(--v2-text-text-base); } +.amc-wg-empty-actions button:focus-visible { outline: 1px solid var(--v2-border-border-strong); outline-offset: 1px; } diff --git a/packages/ui/src/amicode/widget-grid.tsx b/packages/ui/src/amicode/widget-grid.tsx index c6a19b44c0..6fbe047632 100644 --- a/packages/ui/src/amicode/widget-grid.tsx +++ b/packages/ui/src/amicode/widget-grid.tsx @@ -1,4 +1,12 @@ import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { + DragDropProvider, + DragDropSensors, + SortableProvider, + closestCenter, + createSortable, +} from "@thisbeyond/solid-dnd" +import type { DragEvent as DndDragEvent } from "@thisbeyond/solid-dnd" import { WidgetFrame, type WidgetHostCallbacks } from "./widget-frame" import { WidgetConfigForm } from "./widget-config-form" import { formModel, type DashboardEntry, type DashboardState, type WidgetInfo } from "./widget-schema" @@ -7,15 +15,19 @@ import type { Density } from "./widget-tokens" // AMICODE (widget kernel): the dashboard — replaces the hardcoded // AmicodeHomeCards strip. Renders the state's visible entries as sandboxed // WidgetFrames: hero widgets in the top auto-fit grid, tiles in the auto-fit -// row below. "Customize" (in the chrome strip) flips edit mode. In edit mode -// the cards STAY VISIBLE — a light scrim + dashed ring signal "you're -// arranging, not using" — and each card gets ONE compact control pill in its -// top-right corner: ↑↓ reorder within its row, ⚙ config, fork (built-ins), -// × hide. A banner up top orients; a tray at the bottom re-adds hidden -// widgets. Every change calls props.onSave immediately (the app POSTs and -// feeds merged state back down). Reorder is button-based, not drag (declared -// spec deviation), so the corner pill can safely overlay the iframe: clicks -// land host-side above the frame; only a click-DRAG through a frame is eaten. +// row below. Each card's ⋯ menu carries its actions (Move, ⚙ Configure, +// × Remove, + Add widget — opens the TRAY, the one place every available +// widget collects: hidden entries restore, never-pinned registry widgets pin, +// "create new widget…" hands off to the chat); +// "Move" flips MOVE MODE (Kate 2026-07-15: +// no control pill, no name overlay — just a light scrim + dashed ring and the +// cards become draggable). Drag is solid-dnd sortables, move mode only — the +// scrims make the whole grid surface host-owned, so no iframe can eat a +// pointermove; arrow keys on a focused card are the invisible keyboard path. +// Hover reorders a local key order and the save lands ONCE on drop, because +// every save (props.onSave → app POST, merged echo fed back) remounts each +// widget iframe. Move and Add are SEPARATE modes (Kate): Move never shows the +// tray, Add never makes cards draggable; a per-mode banner orients + Done exits. type Bucket = "hero" | "tile" @@ -28,19 +40,16 @@ export function WidgetGrid(props: { context: Record callbacks: WidgetHostCallbacks onSave: (state: DashboardState) => void - onFork: (id: string) => void - /** controlled edit mode (spec T3.1: `customize` lives in the chrome strip); - * omit both to keep the grid's internal toggle */ - editing?: boolean - onEditingChange?: (editing: boolean) => void + /** hands off to the chat with a prefilled "create a widget" prompt — + * authoring is a conversation (amicode_author_widget), not a form */ + onNewWidget?: () => void }) { - const [internalEditing, setInternalEditing] = createSignal(false) - const controlled = () => props.editing !== undefined - const editing = () => (controlled() ? props.editing === true : internalEditing()) - const setEditing = (v: boolean) => { - if (controlled()) props.onEditingChange?.(v) - else setInternalEditing(v) - } + // Grid mode (Kate 2026-07-15: SEPARATE surfaces) — "move" is drag-only + // (scrim + ring + sortables, no tray); "add" is the tray only (cards stay + // live and un-draggable). Done exits either. + const [mode, setMode] = createSignal<"move" | "add" | undefined>(undefined) + const moving = () => mode() === "move" + const busyMode = () => mode() !== undefined const [configOpen, setConfigOpen] = createSignal(undefined) const [empties, setEmpties] = createSignal>({}) // per-card ⋯ menu (customize rethink, Kate 2026-07-15): at most one open, @@ -60,6 +69,23 @@ export function WidgetGrid(props: { } const setHidden = (key: string, value: boolean) => save((entries) => entries.map((e) => (e.key === key ? { ...e, hidden: value } : e))) + /** Pin a registry widget that has no dashboard entry yet — the same shape the + * chat's "Pin to dashboard" appends (key = id; the server normalizes). */ + const pinWidget = (id: string) => save((entries) => [...entries, { key: id, id, hidden: false, config: {} }]) + /** Registry widgets with no dashboard entry at all (chat-authored, unpinned). */ + const unpinned = createMemo(() => props.widgets.filter((w) => !props.dashboard.widget.some((e) => e.id === w.id))) + /** Tray candidates among hidden entries: never offer a widget that is already + * visible on the board (stale hidden duplicates can exist — the pin flows + * append key=id while the server mints hashed keys), and offer each id once. */ + const trayHidden = createMemo(() => { + const onBoard = new Set(visible().map((e) => e.id)) + const seen = new Set() + return hidden().filter((e) => { + if (onBoard.has(e.id) || seen.has(e.id)) return false + seen.add(e.id) + return true + }) + }) const setConfig = (key: string, field: string, value: unknown) => save((entries) => entries.map((e) => (e.key === key ? { ...e, config: { ...e.config, [field]: value } } : e))) const move = (key: string, dir: -1 | 1) => @@ -77,59 +103,58 @@ export function WidgetGrid(props: { return next }) - // position within the visible row (used to disable ↑ on first / ↓ on last) - const posInRow = (key: string, id: string) => { - const group = visible().filter((e) => bucketOf(e.id) === bucketOf(id)) - return { idx: group.findIndex((e) => e.key === key), len: group.length } - } + // ---- drag reorder (move mode) ------------------------------------------- + // The move-mode scrim makes the whole grid surface host-owned (no iframe can + // eat a pointermove), so drag is safe here. CRITICAL: the rendered list must + // NOT reorder mid-drag — solid-dnd previews the shuffle purely with + // transforms (the active card rides the pointer, neighbors slide), and a DOM + // reorder mid-drag moves the active card's transform origin, detaching it + // from the pointer. The array commit (one save — each save is a POST that + // remounts every widget iframe) happens ONCE, at drop. + /** Persist one bucket's new visible order in a single save: the bucket's + * existing flat-array slots are refilled in `keys` order; hidden and + * other-bucket entries keep their exact positions. */ + const applyBucketOrder = (bucket: Bucket, keys: string[]) => + save((entries) => { + const slots: number[] = [] + entries.forEach((e, i) => { + if (!e.hidden && bucketOf(e.id) === bucket) slots.push(i) + }) + const byKey = new Map(entries.map((e) => [e.key, e])) + const ordered = keys.map((k) => byKey.get(k)) + if (slots.length !== keys.length || ordered.some((e) => !e)) return entries // drifted mid-drag — bail + const next = [...entries] + slots.forEach((slot, idx) => { + next[slot] = ordered[idx]! + }) + return next + }) + + const onDragEnd = ({ draggable, droppable }: DndDragEvent) => { + if (!draggable || !droppable) return + const key = String(draggable.id) + const over = String(droppable.id) + if (key === over) return + const target = props.dashboard.widget.find((e) => e.key === key) + if (!target) return + const bucket = bucketOf(target.id) + const keys = (bucket === "hero" ? heroes() : tiles()).map((e) => e.key) + const from = keys.indexOf(key) + const to = keys.indexOf(over) + if (from < 0 || to < 0 || from === to) return // cross-bucket drop lands here (to === -1) and is a no-op + keys.splice(to, 0, ...keys.splice(from, 1)) + applyBucketOrder(bucket, keys) + } const PillButton = (p: { label: string; title: string; onClick: () => void; disabled?: boolean; class?: string }) => ( ) - // the floating corner control cluster — one grouped pill, not five loose boxes - const ControlPill = (p: { entry: DashboardEntry; w: WidgetInfo }) => { - const pos = createMemo(() => posInRow(p.entry.key, p.w.id)) - const hasConfig = createMemo(() => Object.keys(p.w.config).length > 0) - return ( -
- move(p.entry.key, -1)} /> - = pos().len - 1} - onClick={() => move(p.entry.key, 1)} - /> - - - - - setConfigOpen(configOpen() === p.entry.key ? undefined : p.entry.key)} - /> - - - props.onFork(p.w.id)} - /> - - - setHidden(p.entry.key, true)} - /> -
- ) - } + // Move mode carries NO per-card controls (Kate 2026-07-15: no pill, no name + // overlay — just scrim + ring + drag). Configure/remove/fork live in the + // ⋯ menu; arrow keys on a focused card remain the invisible keyboard path. // The card's own ⋯ menu — direct actions on the card in hand, plus the two // grid-level entries (add / arrange) that open the full edit mode. Reuses the @@ -137,7 +162,6 @@ export function WidgetGrid(props: { const CardMenu = (p: { entry: DashboardEntry; w: WidgetInfo }) => { let root: HTMLDivElement | undefined const open = () => menuOpen() === p.entry.key - const pos = createMemo(() => posInRow(p.entry.key, p.w.id)) const hasConfig = createMemo(() => Object.keys(p.w.config).length > 0) const onDocPointer = (e: PointerEvent) => { if (root && !root.contains(e.target as Node)) setMenuOpen(undefined) @@ -169,23 +193,23 @@ export function WidgetGrid(props: { @@ -222,12 +247,39 @@ export function WidgetGrid(props: { ) } + // Edit-mode wrapper: the whole scrimmed card is the drag handle (pointer + // events bubble from the scrim); the pill's buttons stay clickable above it — + // the sensor's activation threshold disambiguates click from drag (same + // proof as the close buttons inside sortable session tabs). + const SortableCell = (p: { entry: DashboardEntry }) => { + const sortable = createSortable(p.entry.key) + return ( +
{ + // invisible keyboard path (no visible ↑↓ controls in move mode) + const dir = + e.key === "ArrowUp" || e.key === "ArrowLeft" ? -1 : e.key === "ArrowDown" || e.key === "ArrowRight" ? 1 : 0 + if (dir === 0) return + e.preventDefault() + move(p.entry.key, dir) + }} + > + +
+ ) + } + const Cell = (p: { entry: DashboardEntry }) => { const info = createMemo(() => infoFor(p.entry.id)) const empty = createMemo(() => empties()[p.entry.key] === true) // a cell shows a labeled placeholder (rather than the frame) when it has // nothing to render but must stay reachable in edit mode - const asPlaceholder = createMemo(() => editing() && (!info() || empty())) + const asPlaceholder = createMemo(() => moving() && (!info() || empty())) // when NOT editing, a cell with nothing to render (missing widget or an // empty-state) collapses out of the grid entirely — otherwise it would @@ -235,20 +287,20 @@ export function WidgetGrid(props: { // display:none so its amc:empty signal keeps flowing and the cell can // re-appear if the widget later has content (hidden frames have no layout, // so emptiness is never inferred from height — it's an explicit signal). - const collapsed = createMemo(() => !editing() && (!info() || empty())) + const collapsed = createMemo(() => !moving() && (!info() || empty())) return (
+ // unknown id (not-yet-synced user widget): reachable only in move mode +
{p.entry.id} — not installed
@@ -271,8 +323,15 @@ export function WidgetGrid(props: { {w().name} · nothing to show right now +
+ setHidden(p.entry.key, true)} + /> +
-
@@ -288,18 +347,16 @@ export function WidgetGrid(props: { callbacks={props.callbacks} onEmpty={(e) => setEmpties((prev) => ({ ...prev, [p.entry.key]: e }))} /> - +
-
{w().name}
- - +
- +
+
- - Customizing your dashboard — use each card's corner controls to reorder ↑↓, configure{" "} - , or remove ×. Changes save as you go. - + + Add a widget — pick one from the tray below; removed widgets collect there too. + + } + > + + Moving widgets — drag cards where you want them (arrow keys work too). Changes save as you go. + +
- 0}> - {/* panel-first (spec T3.4): 2-up when the canvas allows, stacked below */} -
- {(entry) => } -
-
+ + + + + + 0}> + {/* panel-first (spec T3.4): 2-up when the canvas allows, stacked below */} +
+ {(entry) => }}> + e.key)}> + {(entry) => } + + +
+
- 0}> -
- {(entry) => } + 0}> +
+ {(entry) => }}> + e.key)}> + {(entry) => } + + +
+
+ + + {/* every widget hidden → no cards → no ⋯ menus: keep an entry point */} + +
+ No widgets on the board. +
- {/* add-back tray — edit mode only, only when something is hidden */} - 0}> + {/* the widget tray — the ONE place every available widget collects + (⋯ → Add widget lands here): hidden entries restore, never-pinned + registry widgets pin, and create hands off to the chat. */} +
add a widget - + {(entry) => ( )} + + {(w) => ( + + )} + + + +
diff --git a/packages/ui/src/solid-dnd.d.ts b/packages/ui/src/solid-dnd.d.ts new file mode 100644 index 0000000000..84ebaa8a0f --- /dev/null +++ b/packages/ui/src/solid-dnd.d.ts @@ -0,0 +1,12 @@ +// @thisbeyond/solid-dnd's `use:sortable` directive typing (mirrors the app's +// env.d.ts declaration) — required for .tsx files in this package that render +// sortable elements (amicode/widget-grid.tsx). +declare module "solid-js" { + namespace JSX { + interface Directives { + sortable: true + } + } +} + +export {}