@morphika/andami 0.2.4 → 0.2.6

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.
@@ -165,6 +165,32 @@ export default function ProjectGridBlockRenderer({
165
165
  const borderRadius = block.border_radius || 0;
166
166
  const videoMode = block.video_mode || "off";
167
167
 
168
+ // ─── Allow scale hover to overflow the V2 column ───
169
+ // V2 columns have overflow:hidden (CSS Grid img protection). The project
170
+ // grid uses absolute positioning so it doesn't need that protection.
171
+ // When scale hover is active, we relax the parent column's overflow so
172
+ // the scaled card isn't clipped at the column boundary.
173
+ const hasProjects = resolvedProjects.length > 0;
174
+
175
+ useEffect(() => {
176
+ if (hoverEffect !== "scale" && hoverEffect !== "3d") return;
177
+ const el = containerRef.current;
178
+ if (!el) return;
179
+
180
+ // Walk up to find the V2 column (class starts with "sv2-col-")
181
+ let col: HTMLElement | null = el.parentElement;
182
+ while (col && !col.className?.includes("sv2-col-")) {
183
+ col = col.parentElement;
184
+ }
185
+ if (!col) return;
186
+
187
+ const prev = col.style.overflow;
188
+ col.style.overflow = "visible";
189
+ return () => {
190
+ col.style.overflow = prev;
191
+ };
192
+ }, [hoverEffect, hasProjects]);
193
+
168
194
  // ─── Build masonry items (viewport-aware per-card overrides) ───
169
195
  const masonryItems: MasonryItem[] = useMemo(() => {
170
196
  return resolvedProjects.map((proj, i) => {
@@ -226,11 +252,7 @@ export default function ProjectGridBlockRenderer({
226
252
  // animations at once (with stagger). No per-card observer needed.
227
253
  const [gridVisible, setGridVisible] = useState(false);
228
254
 
229
- // Track whether we have projects loaded needed as a dep so the
230
- // IntersectionObserver effect re-runs after the async fetch populates
231
- // resolvedProjects (on the first render the grid div isn't mounted yet
232
- // because the component returns null when resolvedProjects is empty).
233
- const hasProjects = resolvedProjects.length > 0;
255
+ // hasProjects is declared above (used by overflow + entrance effects)
234
256
 
235
257
  useEffect(() => {
236
258
  if (!entranceEnabled || gridVisible || !hasProjects) return;
@@ -342,10 +364,6 @@ export default function ProjectGridBlockRenderer({
342
364
  top: item.y,
343
365
  width: item.width,
344
366
  height: item.height,
345
- // Clip the card when hover scale-up overflows the cell bounds
346
- // so border-radius is preserved visually.
347
- overflow: "hidden",
348
- borderRadius: borderRadius > 0 ? borderRadius : undefined,
349
367
  }}
350
368
  >
351
369
  {entranceEnabled && entranceAnimConfig ? (
@@ -444,18 +462,32 @@ const ProjectCard = memo(function ProjectCard({
444
462
  const handlePlay = useCallback(() => setIsPlaying(true), []);
445
463
  const handlePause = useCallback(() => setIsPlaying(false), []);
446
464
 
447
- // Scale hover: applied to an inner content wrapper so the zoom is
448
- // visible inside the card's borderRadius + overflow:hidden boundary
449
- // (the parent masonry cell also clips, so scaling the card wrapper
450
- // itself would be invisible).
451
- const contentScale =
452
- hoverEffect === "scale" && hovered ? "scale(1.05)" : "scale(1)";
465
+ // Scale transform applied to the Link wrapper so the entire card
466
+ // (including its border-radius clipping shape) scales uniformly.
467
+ // The inner div keeps overflow:hidden + borderRadius for content clipping.
468
+ // Applying scale on the same element as overflow:hidden + borderRadius
469
+ // causes GPU compositing artifacts where the border-radius clips at
470
+ // pre-transform bounds, making rounded corners disappear during scale.
471
+ const scaleTransform =
472
+ hoverEffect === "scale" && hovered ? "scale(1.03)" : "scale(1)";
453
473
 
454
474
  return (
455
475
  <Link
456
476
  href={`/work/${project.slug}`}
457
477
  className="block"
458
- style={{ display: "block", width: "100%", height: "100%" }}
478
+ style={{
479
+ display: "block",
480
+ width: "100%",
481
+ height: "100%",
482
+ // Lift hovered card above neighbours so scale doesn't clip behind them
483
+ position: "relative",
484
+ zIndex: hovered && hoverEffect !== "none" ? 2 : undefined,
485
+ // Scale applied here (outside overflow:hidden + borderRadius) so the
486
+ // entire clipping shape scales with the content.
487
+ transition:
488
+ hoverEffect === "scale" ? "transform 300ms ease" : undefined,
489
+ transform: hoverEffect === "scale" ? scaleTransform : undefined,
490
+ }}
459
491
  onMouseEnter={handleMouseEnter}
460
492
  onMouseLeave={handleMouseLeave}
461
493
  onMouseMove={hoverEffect === "3d" ? handleMouseMove : undefined}
@@ -468,22 +500,13 @@ const ProjectCard = memo(function ProjectCard({
468
500
  height: "100%",
469
501
  overflow: "hidden",
470
502
  borderRadius: radius,
471
- // 3D tilt: applied on the card wrapper (set via handleMouseMove)
503
+ // 3D tilt still uses cardRef for per-pixel mouse tracking
472
504
  transition:
473
505
  hoverEffect === "3d"
474
506
  ? "transform 100ms ease-out"
475
507
  : undefined,
476
508
  }}
477
509
  >
478
- {/* Inner content wrapper — receives scale hover transform */}
479
- <div
480
- style={{
481
- position: "absolute",
482
- inset: 0,
483
- transition: hoverEffect === "scale" ? "transform 300ms ease" : undefined,
484
- transform: hoverEffect === "scale" ? contentScale : undefined,
485
- }}
486
- >
487
510
  {/* Thumbnail image */}
488
511
  {imgSrc ? (
489
512
  <img
@@ -548,9 +571,7 @@ const ProjectCard = memo(function ProjectCard({
548
571
  />
549
572
  )}
550
573
 
551
- </div>{/* end inner content wrapper */}
552
-
553
- {/* Subtitle overlay — outside the scale wrapper so text stays crisp */}
574
+ {/* Subtitle overlay */}
554
575
  {showSubtitle && (
555
576
  <div
556
577
  style={{
@@ -1,5 +1,5 @@
1
1
  import type { TextBlock } from "../../lib/sanity/types";
2
- import { PortableText } from "next-sanity";
2
+ import { PortableText, type PortableTextComponents } from "next-sanity";
3
3
 
4
4
  /** Resolve fontSize: supports numeric px and legacy string enum */
5
5
  function resolvePublicFontSize(fontSize?: number | string): string | undefined {
@@ -79,6 +79,31 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
79
79
  return { className: classes, style: inlineStyle };
80
80
  }
81
81
 
82
+ /** Custom Portable Text components for rich text rendering. */
83
+ const richTextComponents: PortableTextComponents = {
84
+ marks: {
85
+ link: ({ value, children }) => {
86
+ const href = value?.href || "#";
87
+ const blank = value?.blank;
88
+ return (
89
+ <a
90
+ href={href}
91
+ target={blank ? "_blank" : undefined}
92
+ rel={blank ? "noopener noreferrer" : undefined}
93
+ className="underline decoration-current underline-offset-2 hover:opacity-75 transition-opacity"
94
+ >
95
+ {children}
96
+ </a>
97
+ );
98
+ },
99
+ color: ({ value, children }) => {
100
+ const hex = value?.hex;
101
+ if (!hex) return <>{children}</>;
102
+ return <span style={{ color: hex }}>{children}</span>;
103
+ },
104
+ },
105
+ };
106
+
82
107
  export default function TextBlockRenderer({ block }: { block: TextBlock }) {
83
108
  if (!block.text?.length) return null;
84
109
 
@@ -89,7 +114,7 @@ export default function TextBlockRenderer({ block }: { block: TextBlock }) {
89
114
  className={`${className} space-y-[0.75em]`}
90
115
  style={style}
91
116
  >
92
- <PortableText value={block.text} />
117
+ <PortableText value={block.text} components={richTextComponents} />
93
118
  </div>
94
119
  );
95
120
  }
@@ -80,7 +80,7 @@ function wrapInMarks(
80
80
  result = <code>{result}</code>;
81
81
  break;
82
82
  default: {
83
- // Check if it's a link (markDef reference)
83
+ // Check if it's a markDef reference (link or color annotation)
84
84
  const def = getMarkDef(block, mark);
85
85
  if (def && def._type === "link" && def.href) {
86
86
  result = (
@@ -92,6 +92,12 @@ function wrapInMarks(
92
92
  {result}
93
93
  </a>
94
94
  );
95
+ } else if (def && def._type === "color" && def.hex) {
96
+ result = (
97
+ <span style={{ color: def.hex as string }}>
98
+ {result}
99
+ </span>
100
+ );
95
101
  }
96
102
  break;
97
103
  }
@@ -75,10 +75,10 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
75
75
  <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-px rounded-full bg-[#1a1a1a] shadow-xl px-1 py-1"
76
76
  style={{ userSelect: "none" }}
77
77
  >
78
- {/* Select tool */}
78
+ {/* Select tool — Figma-style filled cursor */}
79
79
  <button
80
80
  onClick={() => setCanvasTool("select")}
81
- className={`flex items-center justify-center w-8 h-8 rounded-full text-xs transition-colors ${
81
+ className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
82
82
  tool === "select"
83
83
  ? "bg-white/15 text-white"
84
84
  : "text-neutral-400 hover:text-white hover:bg-white/10"
@@ -86,20 +86,15 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
86
86
  title="Select tool (V)"
87
87
  aria-label="Select tool"
88
88
  >
89
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
90
- <path
91
- d="M2 1L12 7L7 8L5 13L2 1Z"
92
- stroke="currentColor"
93
- strokeWidth="1.5"
94
- strokeLinejoin="round"
95
- />
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
90
+ <path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
96
91
  </svg>
97
92
  </button>
98
93
 
99
- {/* Hand tool */}
94
+ {/* Hand tool — Figma-style */}
100
95
  <button
101
96
  onClick={() => setCanvasTool("hand")}
102
- className={`flex items-center justify-center w-8 h-8 rounded-full text-xs transition-colors ${
97
+ className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
103
98
  tool === "hand"
104
99
  ? "bg-white/15 text-white"
105
100
  : "text-neutral-400 hover:text-white hover:bg-white/10"
@@ -107,12 +102,11 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
107
102
  title="Hand tool (H)"
108
103
  aria-label="Hand tool"
109
104
  >
110
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
111
- {/* Five-finger open hand cleaner icon */}
112
- <path d="M18 11V6.5a1.5 1.5 0 0 0-3 0V11" />
113
- <path d="M15 9.5V4a1.5 1.5 0 0 0-3 0v7" />
114
- <path d="M12 11V5.5a1.5 1.5 0 0 0-3 0v6.5" />
115
- <path d="M9 11V9a1.5 1.5 0 0 0-3 0v3c0 4.42 2.69 8 6 8h1c3.31 0 6-3.58 6-8V11" />
105
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
106
+ <path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
107
+ <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" />
108
+ <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" />
109
+ <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
116
110
  </svg>
117
111
  </button>
118
112
 
@@ -10,6 +10,7 @@ import SortableBlock from "./SortableBlock";
10
10
  import { useColumnDragContext } from "./ColumnDragContext";
11
11
  import { useColumnResize } from "./hooks/useColumnResize";
12
12
  import { InsertionLines } from "./InsertionLines";
13
+ import { isSectionBlockSection } from "../../lib/builder/types";
13
14
 
14
15
  // ============================================
15
16
  // SectionV2Canvas — Renders a V2 section in the builder
@@ -248,8 +249,8 @@ export default function SectionV2Canvas({
248
249
  );
249
250
  })}
250
251
 
251
- {/* Gap buttons: "+ Add Column" — also act as hit-test targets for custom column drag */}
252
- {!previewMode &&
252
+ {/* Gap buttons: "+ Add Column" — hidden for section-level blocks (locked layout) */}
253
+ {!previewMode && !isSectionBlockSection(section) &&
253
254
  gaps.map((gap) => {
254
255
  const isGapTarget =
255
256
  dropTarget?.type === "gap" &&
@@ -215,9 +215,12 @@ export default function SectionV2Column({
215
215
  }, [onAddBlock, column.blocks]);
216
216
 
217
217
  const hasBlocks = (column.blocks || []).length > 0;
218
- const showChrome = (isSelected || isHovered) && !isDraggedColumn;
218
+ // Section-level blocks (e.g. projectGridBlock) own the full section —
219
+ // hide all column management chrome (resize, delete, drag, span badge).
220
+ const isLockedColumn = !!singleSectionBlock;
221
+ const showChrome = (isSelected || isHovered) && !isDraggedColumn && !isLockedColumn;
219
222
  // Show faint outlines when section is hovered but not this specific column
220
- const showFaintOutline = isSectionHovered && !isHovered && !isSelected && !isDraggedColumn;
223
+ const showFaintOutline = isSectionHovered && !isHovered && !isSelected && !isDraggedColumn && !isLockedColumn;
221
224
 
222
225
  // Column-level vertical alignment from blocks' align_v settings
223
226
  const colJustify = getColumnVerticalAlign(column.blocks || []);
@@ -494,7 +497,8 @@ export default function SectionV2Column({
494
497
  </SortableContext>
495
498
 
496
499
  {/* "+" add block button below blocks — absolutely positioned to avoid disrupting flex alignment */}
497
- {hasBlocks && (
500
+ {/* Hidden for section-level blocks (e.g. projectGridBlock) that own the full column */}
501
+ {hasBlocks && !singleSectionBlock && (
498
502
  <div
499
503
  className={`absolute left-0 right-0 z-[3] transition-all ${
500
504
  showChrome ? "opacity-100" : showFaintOutline ? "opacity-30" : "opacity-0 pointer-events-none"
@@ -122,16 +122,17 @@ export default function SortableBlock({
122
122
  onMouseEnter={() => setIsHovered(true)}
123
123
  onMouseLeave={() => setIsHovered(false)}
124
124
  >
125
- {/* Hover/selection outline overlay — scales with zoom */}
125
+ {/* Hover/selection outline overlay — scales with zoom, offset inward so it doesn't overlap column outlines */}
126
126
  <div
127
- className="pointer-events-none absolute inset-0 z-[3] rounded transition-[box-shadow]"
128
- style={
129
- isSelected
127
+ className="pointer-events-none absolute z-[3] rounded transition-[box-shadow]"
128
+ style={{
129
+ inset: `${Math.max(2, Math.min(5, 3 / canvasZoom))}px`,
130
+ ...(isSelected
130
131
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px ${BUILDER_ORANGE}` }
131
132
  : isHovered
132
133
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px rgba(13, 150, 104, 0.4)` }
133
- : undefined
134
- }
134
+ : {}),
135
+ }}
135
136
  />
136
137
 
137
138
  {/* Floating toolbar — centered INSIDE top of block, appears on hover or when selected.
@@ -6,7 +6,7 @@ import { CSS } from "@dnd-kit/utilities";
6
6
  import { makeRowId } from "./DndWrapper";
7
7
  import { useBuilderStore } from "../../lib/builder/store";
8
8
  import { DEFAULT_GRID_WIDTH } from "../../lib/builder/constants";
9
- import { DEVICE_HEIGHTS } from "../../lib/builder/types";
9
+ import { DEVICE_HEIGHTS, isSectionBlockSection } from "../../lib/builder/types";
10
10
  import type { ReactNode } from "react";
11
11
  import type { ContentItem, PageSectionV2, CustomSectionInstance, ParallaxGroup } from "../../lib/sanity/types";
12
12
  import { isPageSectionV2, isCustomSectionInstance, isParallaxGroup } from "../../lib/sanity/types";
@@ -41,6 +41,12 @@ function getSectionLabel(item: ContentItem): string | null {
41
41
  return "Parallax Showcase";
42
42
  }
43
43
  if (isPageSectionV2(item)) {
44
+ const section = item as PageSectionV2;
45
+ if (isSectionBlockSection(section)) {
46
+ // Show the block type name instead of generic "Section"
47
+ const blockType = section.columns?.[0]?.blocks?.[0]?._type;
48
+ if (blockType === "projectGridBlock") return "Project Grid";
49
+ }
44
50
  return "Section";
45
51
  }
46
52
  return null;
@@ -78,6 +84,7 @@ export default function SortableRow({
78
84
  children,
79
85
  }: SortableRowProps) {
80
86
  const previewMode = useBuilderStore((s) => s.previewMode);
87
+ const selectBlock = useBuilderStore((s) => s.selectBlock);
81
88
  const canvasZoom = useBuilderStore((s) => s.canvasZoom);
82
89
  const activeViewport = useBuilderStore((s) => s.activeViewport);
83
90
  const gridSettings = useBuilderStore((s) => s.gridSettings);
@@ -102,6 +109,9 @@ export default function SortableRow({
102
109
  // Determine if this is a PageSectionV2
103
110
  const isV2Section = isPageSectionV2(row);
104
111
  const sectionLabel = getSectionLabel(row);
112
+ // Section-level blocks (e.g. projectGridBlock) own the full section —
113
+ // no column management UI, selecting section auto-selects the block.
114
+ const isLockedSection = isV2Section && isSectionBlockSection(row as PageSectionV2);
105
115
 
106
116
  // For sections: use section settings — viewport-aware for both V1 and V2 sections
107
117
  const resolvedSettings = useMemo(() => {
@@ -223,20 +233,28 @@ export default function SortableRow({
223
233
  className={`relative transition-[opacity,box-shadow] ${
224
234
  isDragging ? "ring-2 ring-[#93278f] ring-offset-2 ring-offset-[#0a0a0a]" : ""
225
235
  }`}
226
- onClick={(e) => { e.stopPropagation(); onSelect(); }}
236
+ onClick={(e) => {
237
+ e.stopPropagation();
238
+ if (isLockedSection) {
239
+ const blockKey = (row as PageSectionV2).columns?.[0]?.blocks?.[0]?._key;
240
+ if (blockKey) { selectBlock(blockKey); return; }
241
+ }
242
+ onSelect();
243
+ }}
227
244
  onMouseEnter={() => setIsHovered(true)}
228
245
  onMouseLeave={() => setIsHovered(false)}
229
246
  >
230
- {/* Selection/hover outline — scales with zoom */}
247
+ {/* Selection/hover outline — scales with zoom, offset outward so it doesn't overlap column/block outlines */}
231
248
  <div
232
- className="pointer-events-none absolute inset-0 z-[1] transition-[box-shadow]"
233
- style={
234
- isSelected
249
+ className="pointer-events-none absolute z-[1] transition-[box-shadow]"
250
+ style={{
251
+ inset: `${-Math.max(2, Math.min(5, 3 / canvasZoom))}px`,
252
+ ...(isSelected
235
253
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px #93278f` }
236
254
  : isHovered
237
255
  ? { boxShadow: `inset 0 0 0 ${Math.max(2, Math.min(5, 3 / canvasZoom))}px rgba(147, 39, 143, 0.4)` }
238
- : undefined
239
- }
256
+ : {}),
257
+ }}
240
258
  />
241
259
 
242
260
  {/* Section toolbar — wide pill aligned top-left outside the row */}
@@ -249,7 +267,14 @@ export default function SortableRow({
249
267
  transformOrigin: "top right",
250
268
  width: "90px",
251
269
  }}
252
- onClick={(e) => { e.stopPropagation(); onSelect(); }}
270
+ onClick={(e) => {
271
+ e.stopPropagation();
272
+ if (isLockedSection) {
273
+ const blockKey = (row as PageSectionV2).columns?.[0]?.blocks?.[0]?._key;
274
+ if (blockKey) { selectBlock(blockKey); return; }
275
+ }
276
+ onSelect();
277
+ }}
253
278
  >
254
279
  {/* Main toolbar — drag + actions */}
255
280
  <div
@@ -301,8 +326,8 @@ export default function SortableRow({
301
326
  </button>
302
327
  </div>
303
328
 
304
- {/* Add column — shown for V2 sections and regular rows, hidden for closed sections (V1 PageSection) */}
305
- {(!sectionLabel || isV2Section) && (
329
+ {/* Add column — shown for V2 sections and regular rows, hidden for section blocks and closed sections */}
330
+ {(!sectionLabel || isV2Section) && !isLockedSection && (
306
331
  <button
307
332
  onClick={(e) => { e.stopPropagation(); onAddColumn(); }}
308
333
  onPointerDown={(e) => e.stopPropagation()}
@@ -6,7 +6,6 @@ import { getEffectiveValue, setResponsiveOverride } from "../../../lib/builder/r
6
6
  import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
7
7
  import type { DeviceViewport } from "../../../lib/builder/types";
8
8
  import {
9
- TextIcon,
10
9
  TypographyIcon,
11
10
  ColumnsIcon,
12
11
  } from "./section-icons";
@@ -271,8 +270,8 @@ export default function TextBlockEditor({ block }: { block: TextBlock }) {
271
270
  <>
272
271
  <ViewportBadge />
273
272
 
274
- {/* Text section: Style, Color, Align */}
275
- <SettingsSection title="Text" defaultOpen icon={<TextIcon />}>
273
+ {/* Typography section: Style, Color, Align, Size, Weight, Line height, Letter spacing, Transform */}
274
+ <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
276
275
  <SettingsField label="Style">
277
276
  <TextStylePicker
278
277
  presets={presets}
@@ -309,10 +308,7 @@ export default function TextBlockEditor({ block }: { block: TextBlock }) {
309
308
  ))}
310
309
  </div>
311
310
  </ResponsiveStyleField>
312
- </SettingsSection>
313
311
 
314
- {/* Typography section: Size, Weight, Line height, Letter spacing */}
315
- <SettingsSection title="Typography" defaultOpen icon={<TypographyIcon />}>
316
312
  <ResponsiveStyleField label="Size" subProp="fontSize" viewport={viewport} isOverridden={viewport !== "desktop" && hasStyleOverride("fontSize")} onReset={resetStyleOverride}>
317
313
  <div className="flex items-center gap-0 bg-[#f5f5f5] rounded-lg overflow-hidden transition-all border border-transparent focus-within:bg-white focus-within:border-[#076bff] focus-within:shadow-[0_0_0_3px_rgba(7,107,255,0.06)]">
318
314
  <input