Skip to content

Implement Grid Smart Layout - Priority-based span distribution for clean bento grids #764

@ssilvius

Description

@ssilvius

Goal

Add a layout computation utility to Grid that automatically distributes colSpan and rowSpan across items based on priority signals, producing clean grid fills with visual hierarchy. Grid.Item already has an unused priority prop (primary/secondary/tertiary) — make it functional.

Exact Implementation Requirements

Required Interface/Class Structure

// Grid layout utility - computes span assignments for clean fills
interface LayoutItem {
  colSpan: 1 | 2 | 3 | 4;
  rowSpan: 1 | 2 | 3;
}

interface ComputeLayoutOptions {
  /** Number of grid columns (1-6) */
  columns: number;
  /** Total number of items to place */
  itemCount: number;
  /** Priority scores per item (higher = more prominent). Index-aligned. */
  priorities: number[];
  /** Minimum priority score required for wide treatment (colSpan > 1). Default: 3 */
  wideThreshold?: number;
  /** Minimum priority score required for tall treatment (rowSpan > 1). Default: top scorer */
  tallThreshold?: number;
}

/**
 * Returns span assignments for each item, ensuring:
 * - Total cells consumed = rows * columns (no empty cells)
 * - Top scorer gets rowSpan=2 (tall) when itemCount >= 6
 * - Remaining extras distributed as colSpan=2 (wide) to next highest scorers
 * - When base grid has no extras (itemCount % columns === 0), adds a row
 */
function computeGridLayout(options: ComputeLayoutOptions): LayoutItem[];

Grid.Item Priority Prop

// Make the existing priority prop functional
interface GridItemProps {
  priority?: 'primary' | 'secondary' | 'tertiary';
  // When used with a smart layout Grid, priority maps to span assignments:
  // primary   -> eligible for rowSpan=2 (tall hero)
  // secondary -> eligible for colSpan=2 (wide feature)
  // tertiary  -> standard 1x1 (default)
}

Behavior Requirements

  • When itemCount >= 6 and base extras (ceil(itemCount/columns) * columns - itemCount) are available, the top-priority item gets rowSpan=2
  • When base extras are 0 (perfect grid fill like 9 items in 3 columns), automatically add one row to create hierarchy: 1 tall + (columns-1) wide items
  • Wide items (colSpan=2) are assigned to items scoring above wideThreshold until extras are exhausted
  • Every extra cell must be consumed — no empty cells in the final grid
  • Grid should apply grid-auto-flow: dense (via grid-flow-dense class) when smart layout is active, so CSS Grid backfills gaps

Grid math:

  • Each wide item costs 1 extra cell (occupies 2 cells, represents 1 item)
  • Each tall item costs 1 extra cell (occupies 2 cells across rows, represents 1 item)
  • extras = (rows * columns) - itemCount must equal numTall + numWide

Example distributions (3-column grid):

Items Base Rows Extras Layout
6 2 0 -> add row -> 3 1 tall + 2 wide + 3 standard
7 3 2 1 tall + 1 wide + 5 standard
8 3 1 1 tall + 7 standard
9 3 0 -> add row -> 3 1 tall + 2 wide + 6 standard
10 4 2 1 tall + 1 wide + 8 standard
12 4 0 -> add row -> 3 1 tall + 2 wide + 9 standard

Error Handling

  • If itemCount is 0, return empty array
  • If columns is not 1-6, throw RangeError('columns must be between 1 and 6')
  • If priorities.length !== itemCount, throw RangeError('priorities length must match itemCount')
  • If itemCount < 6, skip tall/wide distribution entirely (not enough items for hierarchy)

Acceptance Criteria

Functional Tests Required

import { computeGridLayout } from '@/lib/grid-layout';

// Clean fill with extras
test('8 items in 3 columns: 1 tall, 7 standard', () => {
  const result = computeGridLayout({
    columns: 3,
    itemCount: 8,
    priorities: [5, 4, 4, 3, 3, 2, 2, 1],
  });
  const totalCells = result.reduce((sum, item) => sum + item.colSpan * item.rowSpan, 0);
  expect(totalCells).toBe(9); // 3 rows * 3 cols
  expect(result[0]).toEqual({ colSpan: 1, rowSpan: 2 }); // top scorer is tall
  expect(result.filter(r => r.colSpan > 1)).toHaveLength(0); // no wide (only 1 extra, used by tall)
});

// Perfect fill needs extra row
test('9 items in 3 columns: adds row, 1 tall + 2 wide', () => {
  const result = computeGridLayout({
    columns: 3,
    itemCount: 9,
    priorities: [6, 5, 4, 3, 3, 3, 2, 2, 1],
  });
  const totalCells = result.reduce((sum, item) => sum + item.colSpan * item.rowSpan, 0);
  expect(totalCells).toBe(12); // 4 rows * 3 cols (added 1 row)
  expect(result[0]).toEqual({ colSpan: 1, rowSpan: 2 }); // tall
  expect(result.filter(r => r.colSpan === 2)).toHaveLength(2); // 2 wide
});

// Small collections skip hierarchy
test('4 items: all standard, no tall/wide', () => {
  const result = computeGridLayout({
    columns: 3,
    itemCount: 4,
    priorities: [5, 4, 3, 2],
  });
  expect(result.every(r => r.colSpan === 1 && r.rowSpan === 1)).toBe(true);
});

// Empty
test('0 items returns empty', () => {
  expect(computeGridLayout({ columns: 3, itemCount: 0, priorities: [] })).toEqual([]);
});

// Validation
test('mismatched priorities throws', () => {
  expect(() => computeGridLayout({ columns: 3, itemCount: 5, priorities: [1, 2] }))
    .toThrow('priorities length must match itemCount');
});

Performance Requirements

  • O(n log n) for sorting priorities, O(n) for assignment pass
  • No allocations beyond the output array and sorted index array

TypeScript Requirements

  • TypeScript strict mode, no any types
  • Return type fully specified as LayoutItem[]
  • All parameters validated

Build Requirements

  • Biome 2.3.2 linting passes
  • No forEach, no var, no console.log

What NOT to Include

  • Application-specific scoring logic (courses, products, etc.) — consumers compute their own priority scores
  • Visual styling or gradient generation — that's consumer-side
  • Responsive breakpoint logic — consumers add max-md:col-span-1 max-md:row-span-1 themselves
  • Grid preset integration (separate follow-up if needed)

File Locations

  • Implementation: src/lib/grid-layout.ts
  • Unit Tests: src/lib/grid-layout.test.ts
  • Types: inline in implementation file
  • Export from: src/lib/index.ts

Integration Requirements

Dependencies

  • None — pure computation, no DOM or React dependency

Usage Examples

import { computeGridLayout } from '@rafters/grid-layout';
import { Grid } from '@rafters/grid';

// Consumer scores their items however they want
const priorities = courses.map(c => scoreCourse(c.frontmatter));

const layout = computeGridLayout({
  columns: 3,
  itemCount: courses.length,
  priorities,
});

// Apply to Grid
<Grid columns={3} gap="6" className="grid-flow-dense max-md:grid-cols-1">
  {courses.map((course, i) => (
    <Grid.Item
      key={course.slug}
      colSpan={layout[i].colSpan}
      rowSpan={layout[i].rowSpan}
      className="max-md:col-span-1 max-md:row-span-1"
    >
      <CourseCard
        slug={course.slug}
        frontmatter={course.frontmatter}
        hero={layout[i].colSpan > 1 || layout[i].rowSpan > 1}
      />
    </Grid.Item>
  ))}
</Grid>

Success Criteria

  • All functional tests pass
  • Every test case produces zero empty cells (total cells = rows * columns)
  • TypeScript compiles without errors
  • Biome lint passes
  • Exported from package index

This issue is complete when: computeGridLayout produces mathematically correct clean-fill layouts for any item count 0-100 across all supported column counts (1-6), with tall/wide distribution for counts >= 6.

Context & References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions