▍ humdrum codex / soft

Dashboard: drag-to-reorder + corner-resize via Edit layout mode

87fa9c56224defad0ef0a70c668da18def5cb11c
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 13:25

parent ca07f949

Dashboard: drag-to-reorder + corner-resize via Edit layout mode

Cards can now be rearranged and freely resized directly on the
dashboard. An "Edit layout" toggle reveals dashed tile outlines, makes
every card a dnd-kit drag source for reordering, and shows a
bottom-right corner handle that resizes the card by whole grid cells
(free w×h). While editing, card content interaction is paused.

Layout model moves from the 5-name size enum to explicit cols/rows
spans:
- db/schema.ts: add cols (default 1) + rows (default 2); size kept but
  now vestigial. Backfilled existing rows from their old size.
- schema/types/store/import updated to carry cols/rows.
- lib/layout.ts: spanStyle(cols, rows) + grid constants; CardBody's
  short-tile heuristic now keys off rows.
- Settings: drop the per-row size dropdown (resize lives on the
  dashboard now); reorder arrows stay as an accessible fallback.

Position changes renumber only enabled cards into their existing
position slots, leaving hidden sources untouched. cols/rows persist via
the existing PATCH /api/sources/[id].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

11 files changed

components/Dashboard.tsx +237 −26
@@ -1,10 +1,25 @@
 "use client";
 
-import { useCallback, useEffect, useState } from "react";
+import { useCallback, useEffect, useRef, useState } from "react";
 import { useRouter } from "next/navigation";
+import {
+  DndContext,
+  PointerSensor,
+  closestCenter,
+  useSensor,
+  useSensors,
+  type DragEndEvent,
+} from "@dnd-kit/core";
+import {
+  SortableContext,
+  arrayMove,
+  rectSortingStrategy,
+  useSortable,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
 import type { SourceState } from "@/lib/types";
 import { useAction } from "@/hooks/useAction";
-import { tileStyle } from "@/lib/layout";
+import { spanStyle, GRID_COL_MIN, GRID_ROW_PX, GRID_GAP, MAX_ROWS } from "@/lib/layout";
 import { SourceCard } from "./cards/SourceCard";
 import { ThingsCard } from "./cards/ThingsCard";
 
@@ -15,6 +30,7 @@   const [loading, setLoading] = useState(true);
   const [refreshingAll, setRefreshingAll] = useState(false);
   const [refreshingId, setRefreshingId] = useState<string | null>(null);
   const [generating, setGenerating] = useState(false);
+  const [editing, setEditing] = useState(false);
 
   // Initial paint from cache, then refresh stale sources in the background.
   useEffect(() => {
@@ -65,6 +81,59 @@       setGenerating(false);
     }
   }, [router]);
 
+  // Persist a layout patch (position / cols / rows) for one source.
+  const patchSource = useCallback((id: string, patch: Record<string, number>) => {
+    void fetch(`/api/sources/${id}`, {
+      method: "PATCH",
+      headers: { "Content-Type": "application/json" },
+      body: JSON.stringify(patch),
+    });
+  }, []);
+
+  // Drag-reorder: renumber enabled cards into the same set of position slots they
+  // already occupy, leaving disabled (hidden) sources' positions untouched.
+  const onDragEnd = useCallback(
+    (e: DragEndEvent) => {
+      const { active, over } = e;
+      if (!over || active.id === over.id) return;
+      setItems((prev) => {
+        const enabled = prev.filter((it) => it.source.enabled);
+        const oldIndex = enabled.findIndex((it) => it.source.id === active.id);
+        const newIndex = enabled.findIndex((it) => it.source.id === over.id);
+        if (oldIndex < 0 || newIndex < 0) return prev;
+        const slots = enabled.map((it) => it.source.position).sort((a, b) => a - b);
+        const reordered = arrayMove(enabled, oldIndex, newIndex);
+        const posById = new Map<string, number>();
+        reordered.forEach((it, i) => {
+          const pos = slots[i];
+          if (pos !== it.source.position) patchSource(it.source.id, { position: pos });
+          posById.set(it.source.id, pos);
+        });
+        return prev
+          .map((it) =>
+            posById.has(it.source.id)
+              ? { ...it, source: { ...it.source, position: posById.get(it.source.id)! } }
+              : it,
+          )
+          .sort((a, b) => a.source.position - b.source.position);
+      });
+    },
+    [patchSource],
+  );
+
+  // Live local resize, persisted on release.
+  const resize = useCallback(
+    (id: string, cols: number, rows: number, commit: boolean) => {
+      setItems((prev) =>
+        prev.map((it) =>
+          it.source.id === id ? { ...it, source: { ...it.source, cols, rows } } : it,
+        ),
+      );
+      if (commit) patchSource(id, { cols, rows });
+    },
+    [patchSource],
+  );
+
   useAction(
     {
       id: "refresh-all",
@@ -86,7 +155,18 @@     },
     [generatePaper],
   );
 
+  const sensors = useSensors(
+    useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
+  );
+
   const enabled = items.filter((it) => it.source.enabled);
+  const gridStyle: React.CSSProperties = {
+    display: "grid",
+    gridTemplateColumns: `repeat(auto-fill, minmax(${GRID_COL_MIN}px, 1fr))`,
+    gridAutoRows: `${GRID_ROW_PX}px`,
+    gridAutoFlow: "dense",
+    gap: GRID_GAP,
+  };
 
   return (
     <main className="w-full px-6 py-8">
@@ -99,47 +179,178 @@             day: "numeric",
           })}
         </p>
         <div className="flex gap-2">
+          <Btn onClick={() => setEditing((v) => !v)} primary={editing}>
+            {editing ? "Done" : "Edit layout"}
+          </Btn>
           <Btn onClick={generatePaper} disabled={generating}>
             {generating ? "Generating…" : "Generate paper"}
           </Btn>
-          <Btn onClick={refreshAll} disabled={refreshingAll} primary>
+          <Btn onClick={refreshAll} disabled={refreshingAll} primary={!editing}>
             {refreshingAll ? "Refreshing…" : "Refresh all"}
           </Btn>
         </div>
       </div>
 
+      {editing && (
+        <p className="text-xs mb-3" style={{ color: "var(--text-faint)" }}>
+          Drag cards to reorder · drag a card&rsquo;s bottom-right corner to resize · click
+          Done when finished.
+        </p>
+      )}
+
       {loading ? (
         <p style={{ color: "var(--text-faint)" }}>Loading…</p>
+      ) : editing ? (
+        <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
+          <SortableContext items={enabled.map((it) => it.source.id)} strategy={rectSortingStrategy}>
+            <div style={gridStyle}>
+              {enabled.map((it) => (
+                <SortableTile key={it.source.id} state={it} onResize={resize}>
+                  <CardFor
+                    state={it}
+                    onRefresh={() => refreshOne(it.source.id)}
+                    refreshing={refreshingId === it.source.id}
+                  />
+                </SortableTile>
+              ))}
+            </div>
+          </SortableContext>
+        </DndContext>
       ) : (
-        <div
-          style={{
-            display: "grid",
-            gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
-            gridAutoRows: "128px",
-            gridAutoFlow: "dense",
-            gap: 16,
-          }}
-        >
+        <div style={gridStyle}>
           {enabled.map((it) => (
-            <div key={it.source.id} style={{ ...tileStyle(it.source.size), minHeight: 0 }}>
-              {it.source.kind === "todos" ? (
-                <ThingsCard
-                  state={it}
-                  onRefresh={() => refreshOne(it.source.id)}
-                  refreshing={refreshingId === it.source.id}
-                />
-              ) : (
-                <SourceCard
-                  state={it}
-                  onRefresh={() => refreshOne(it.source.id)}
-                  refreshing={refreshingId === it.source.id}
-                />
-              )}
+            <div
+              key={it.source.id}
+              style={{ ...spanStyle(it.source.cols, it.source.rows), minHeight: 0 }}
+            >
+              <CardFor
+                state={it}
+                onRefresh={() => refreshOne(it.source.id)}
+                refreshing={refreshingId === it.source.id}
+              />
             </div>
           ))}
         </div>
       )}
     </main>
+  );
+}
+
+function CardFor({
+  state,
+  onRefresh,
+  refreshing,
+}: {
+  state: SourceState;
+  onRefresh: () => void;
+  refreshing: boolean;
+}) {
+  return state.source.kind === "todos" ? (
+    <ThingsCard state={state} onRefresh={onRefresh} refreshing={refreshing} />
+  ) : (
+    <SourceCard state={state} onRefresh={onRefresh} refreshing={refreshing} />
+  );
+}
+
+// A draggable, corner-resizable grid tile (edit mode only).
+function SortableTile({
+  state,
+  onResize,
+  children,
+}: {
+  state: SourceState;
+  onResize: (id: string, cols: number, rows: number, commit: boolean) => void;
+  children: React.ReactNode;
+}) {
+  const { source } = state;
+  const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
+    id: source.id,
+  });
+  const nodeRef = useRef<HTMLDivElement | null>(null);
+
+  // Corner-resize: translate pointer movement into whole grid-cell span changes.
+  const onResizeStart = (e: React.PointerEvent) => {
+    e.preventDefault();
+    e.stopPropagation();
+    const grid = nodeRef.current?.parentElement;
+    if (!grid) return;
+    const cols = getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length || 1;
+    const cellW = (grid.clientWidth - GRID_GAP * (cols - 1)) / cols;
+    const stepX = cellW + GRID_GAP;
+    const stepY = GRID_ROW_PX + GRID_GAP;
+    const startX = e.clientX;
+    const startY = e.clientY;
+    const startCols = source.cols;
+    const startRows = source.rows;
+    (e.target as Element).setPointerCapture(e.pointerId);
+
+    const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n));
+    const move = (ev: PointerEvent) => {
+      const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols);
+      const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS);
+      onResize(source.id, nextCols, nextRows, false);
+    };
+    const up = (ev: PointerEvent) => {
+      const nextCols = clamp(startCols + Math.round((ev.clientX - startX) / stepX), 1, cols);
+      const nextRows = clamp(startRows + Math.round((ev.clientY - startY) / stepY), 1, MAX_ROWS);
+      onResize(source.id, nextCols, nextRows, true);
+      window.removeEventListener("pointermove", move);
+      window.removeEventListener("pointerup", up);
+    };
+    window.addEventListener("pointermove", move);
+    window.addEventListener("pointerup", up);
+  };
+
+  return (
+    <div
+      ref={(el) => {
+        setNodeRef(el);
+        nodeRef.current = el;
+      }}
+      style={{
+        ...spanStyle(source.cols, source.rows),
+        minHeight: 0,
+        position: "relative",
+        transform: CSS.Transform.toString(transform),
+        transition,
+        opacity: isDragging ? 0.4 : 1,
+        zIndex: isDragging ? 10 : undefined,
+        cursor: "grab",
+        outline: "2px dashed var(--border-strong)",
+        outlineOffset: -2,
+        borderRadius: 8,
+      }}
+      {...attributes}
+      {...listeners}
+    >
+      {/* Block inner clicks/scroll while editing; this overlay is the drag surface. */}
+      <div style={{ pointerEvents: "none", height: "100%" }}>{children}</div>
+
+      {/* Resize handle — bottom-right corner. */}
+      <div
+        onPointerDown={onResizeStart}
+        title="Drag to resize"
+        style={{
+          position: "absolute",
+          right: 2,
+          bottom: 2,
+          width: 16,
+          height: 16,
+          cursor: "nwse-resize",
+          color: "var(--text-on-accent)",
+          background: "var(--accent)",
+          borderRadius: 4,
+          display: "flex",
+          alignItems: "center",
+          justifyContent: "center",
+          fontSize: 10,
+          lineHeight: 1,
+          zIndex: 5,
+        }}
+      >
+        ⤡
+      </div>
+    </div>
   );
 }
 
components/SettingsView.tsx +2 −16
@@ -7,8 +7,6 @@ import { downloadExport } from "@/lib/export";
 import { uploadImport } from "@/lib/import";
 import type { SourceState } from "@/lib/types";
 import type { Settings } from "@/lib/schemas/setting";
-import { SOURCE_SIZES } from "@/lib/schemas/source";
-import { SIZE_LABELS } from "@/lib/layout";
 
 const cardStyle = {
   background: "var(--bg-card)",
@@ -204,7 +202,8 @@           ))}
         </div>
         <p className="mt-3 text-xs" style={{ color: "var(--text-faint)" }}>
           Uncheck a card to hide it from the dashboard. Every source stays available — nothing
-          is ever removed.
+          is ever removed. Drag to reorder and resize cards from the dashboard&rsquo;s
+          &ldquo;Edit layout&rdquo; mode.
         </p>
       </Section>
 
@@ -276,19 +275,6 @@         <RefreshControl
           seconds={source.refreshSeconds}
           onChange={(s) => onPatch({ refreshSeconds: s })}
         />
-        <select
-          value={source.size}
-          onChange={(e) => onPatch({ size: e.target.value })}
-          className="text-xs px-1 py-0.5"
-          style={inputStyle}
-          aria-label="Tile size"
-        >
-          {SOURCE_SIZES.map((s) => (
-            <option key={s} value={s}>
-              {SIZE_LABELS[s]}
-            </option>
-          ))}
-        </select>
         <button className="text-xs px-1" style={{ color: "var(--text-muted)" }} onClick={onUp} disabled={!onUp}>↑</button>
         <button className="text-xs px-1" style={{ color: "var(--text-muted)" }} onClick={onDown} disabled={!onDown}>↓</button>
         {configurable && (
components/cards/CardBodies.tsx +4 −5
@@ -15,8 +15,7 @@ import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky";
 import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
 import type { SportsPayload } from "@/lib/schemas/sources/sports";
 import type { LinksPayload } from "@/lib/schemas/sources/links";
-import type { SourceKind, SourceSize } from "@/lib/schemas/source";
-import { TILE_SPANS } from "@/lib/layout";
+import type { SourceKind } from "@/lib/schemas/source";
 
 const muted = { color: "var(--text-muted)" };
 const faint = { color: "var(--text-faint)" };
@@ -323,13 +322,13 @@
 export function CardBody({
   kind,
   payload,
-  size,
+  rows,
 }: {
   kind: SourceKind;
   payload: unknown;
-  size: SourceSize;
+  rows: number;
 }) {
-  const shortTile = (TILE_SPANS[size]?.row ?? 2) <= 1;
+  const shortTile = rows <= 1;
   switch (kind) {
     case "weather":
       return <WeatherBody p={payload as WeatherPayload} row={shortTile} />;
components/cards/SourceCard.tsx +1 −1
@@ -88,7 +88,7 @@     body = <Note>Loading…</Note>;
   } else if (!snapshot.ok) {
     body = <Note tone="error">{snapshot.error ?? "Failed to load."}</Note>;
   } else {
-    body = <CardBody kind={source.kind} payload={snapshot.payload} size={source.size} />;
+    body = <CardBody kind={source.kind} payload={snapshot.payload} rows={source.rows} />;
   }
 
   return (
db/schema.ts +3 −1
@@ -18,7 +18,9 @@   enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
   config: text("config", { mode: "json" }).notNull().default(sql`'{}'`),
   refreshSeconds: integer("refresh_seconds").notNull().default(900),
   position: integer("position").notNull().default(0),
-  size: text("size").notNull().default("md"),
+  size: text("size").notNull().default("md"), // vestigial; layout now uses cols/rows
+  cols: integer("cols").notNull().default(1), // grid column span (free w×h bento)
+  rows: integer("rows").notNull().default(2), // grid row span
 });
 
 // Cached fetch result per source.
lib/layout.ts +14 −0
@@ -20,6 +20,20 @@     gridRow: `span ${span.row}`,
   };
 }
 
+// Free w×h bento: a card spans `cols` columns and `rows` rows directly.
+// The grid track is GRID_COL_MIN wide and GRID_ROW_PX tall (see Dashboard).
+export const GRID_COL_MIN = 280; // px — min column width (auto-fill minmax)
+export const GRID_ROW_PX = 128; // px — fixed row height
+export const GRID_GAP = 16; // px — grid gap
+export const MAX_ROWS = 8; // resize clamp for row span
+
+export function spanStyle(cols: number, rows: number): React.CSSProperties {
+  return {
+    gridColumn: `span ${Math.max(1, cols)}`,
+    gridRow: `span ${Math.max(1, rows)}`,
+  };
+}
+
 export const SIZE_LABELS: Record<SourceSize, string> = {
   sm: "Small",
   md: "Medium",
lib/schemas/source.ts +3 −1
@@ -40,7 +40,9 @@   enabled: z.boolean().default(true),
   config: SourceConfig.default({}),
   refreshSeconds: z.number().int().min(30).max(86_400).default(900),
   position: z.number().int().default(0),
-  size: SourceSize.default("md"),
+  size: SourceSize.default("md"), // vestigial; layout now uses cols/rows
+  cols: z.number().int().min(1).max(6).default(1), // grid column span
+  rows: z.number().int().min(1).max(8).default(2), // grid row span
 });
 export type Source = z.infer<typeof Source>;
 
lib/store.ts +4 −0
@@ -29,6 +29,8 @@     config: Record<string, unknown>;
     refreshSeconds: number;
     position: number;
     size: string;
+    cols: number;
+    rows: number;
   }>,
 ) {
   if (Object.keys(patch).length > 0) {
@@ -188,6 +190,8 @@           enabled: s.enabled !== false,
           config: (s.config as Record<string, unknown>) ?? {},
           refreshSeconds: Number(s.refreshSeconds ?? 900),
           position: Number(s.position ?? 0),
+          cols: Number(s.cols ?? 1),
+          rows: Number(s.rows ?? 2),
         })
         .run();
     }
lib/types.ts +2 −0
@@ -11,6 +11,8 @@   config: Record<string, unknown>;
   refreshSeconds: number;
   position: number;
   size: SourceSize;
+  cols: number;
+  rows: number;
 }
 
 export interface ClientSnapshot {
package.json +4 −0
@@ -15,6 +15,10 @@     "db:seed": "tsx scripts/seed.ts",
     "icons": "tsx scripts/generate-icons.mjs"
   },
   "dependencies": {
+    "@dnd-kit/core": "^6.3.1",
+    "@dnd-kit/modifiers": "^9.0.0",
+    "@dnd-kit/sortable": "^10.0.0",
+    "@dnd-kit/utilities": "^3.2.2",
     "better-sqlite3": "^11.8.1",
     "date-fns": "^4.1.0",
     "drizzle-orm": "^0.45.0",
pnpm-lock.yaml +72 −0
@@ -8,6 +8,18 @@ importers:
 
   .:
     dependencies:
+      '@dnd-kit/core':
+        specifier: ^6.3.1
+        version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      '@dnd-kit/modifiers':
+        specifier: ^9.0.0
+        version: 9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+      '@dnd-kit/sortable':
+        specifier: ^10.0.0
+        version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)
+      '@dnd-kit/utilities':
+        specifier: ^3.2.2
+        version: 3.2.2(react@19.2.6)
       better-sqlite3:
         specifier: ^11.8.1
         version: 11.10.0
@@ -95,6 +107,34 @@
   '@babel/helper-validator-identifier@7.28.5':
     resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==}
     engines: {node: '>=6.9.0'}
+
+  '@dnd-kit/accessibility@3.1.1':
+    resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
+    peerDependencies:
+      react: '>=16.8.0'
+
+  '@dnd-kit/core@6.3.1':
+    resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
+    peerDependencies:
+      react: '>=16.8.0'
+      react-dom: '>=16.8.0'
+
+  '@dnd-kit/modifiers@9.0.0':
+    resolution: {integrity: sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==}
+    peerDependencies:
+      '@dnd-kit/core': ^6.3.0
+      react: '>=16.8.0'
+
+  '@dnd-kit/sortable@10.0.0':
+    resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
+    peerDependencies:
+      '@dnd-kit/core': ^6.3.0
+      react: '>=16.8.0'
+
+  '@dnd-kit/utilities@3.2.2':
+    resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
+    peerDependencies:
+      react: '>=16.8.0'
 
   '@drizzle-team/brocli@0.10.2':
     resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==}
@@ -2036,6 +2076,38 @@       js-tokens: 4.0.0
       picocolors: 1.1.1
 
   '@babel/helper-validator-identifier@7.28.5': {}
+
+  '@dnd-kit/accessibility@3.1.1(react@19.2.6)':
+    dependencies:
+      react: 19.2.6
+      tslib: 2.8.1
+
+  '@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
+    dependencies:
+      '@dnd-kit/accessibility': 3.1.1(react@19.2.6)
+      '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+      react: 19.2.6
+      react-dom: 19.2.6(react@19.2.6)
+      tslib: 2.8.1
+
+  '@dnd-kit/modifiers@9.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+    dependencies:
+      '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+      react: 19.2.6
+      tslib: 2.8.1
+
+  '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react@19.2.6)':
+    dependencies:
+      '@dnd-kit/core': 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
+      '@dnd-kit/utilities': 3.2.2(react@19.2.6)
+      react: 19.2.6
+      tslib: 2.8.1
+
+  '@dnd-kit/utilities@3.2.2(react@19.2.6)':
+    dependencies:
+      react: 19.2.6
+      tslib: 2.8.1
 
   '@drizzle-team/brocli@0.10.2': {}