Takazudo Modular Styleguide

MDX/OxiGrid

Accumulator Visualization
Default
In Figure Wrapper
Init End Markers
Melody Pattern
Tie Example
With Labels
With Seq Buttons

Component Source

oxi-grid.tsx

import { Fragment, type ComponentChildren } from 'preact';
import { useMemo } from 'preact/hooks';

/** Data for a single pad in the grid */
interface PadData {
  /** Column index (0-based, left to right) */
  col: number;
  /** Row index (0-based, bottom to top) */
  row: number;
  /** CSS color string for the pad (e.g., 'rgb(0, 200, 80)') */
  color?: string;
  /** Short text label displayed on the pad */
  label?: string;
}

/** Props for the OxiGrid component */
interface OxiGridProps {
  /** Number of horizontal steps (columns). Default: 16 */
  steps?: number;
  /** Number of vertical rows. Default: 8 */
  rows?: number;
  /** Array of active pads with position, color, and optional label */
  pads?: PadData[];
  /** Highlighted playhead column index (0-based) */
  playhead?: number;
  /**
   * Loop start marker column index (0-based).
   * Pass a number for a single marker that spans all rows, or an array where
   * each entry is the marker column for that row (array index = row index,
   * 0-based from bottom). Use `undefined` within the array to skip a row.
   */
  initStep?: number | Array<number | undefined>;
  /**
   * Loop end marker column index (0-based).
   * Pass a number for a single marker that spans all rows, or an array where
   * each entry is the marker column for that row (array index = row index,
   * 0-based from bottom). Use `undefined` within the array to skip a row.
   */
  endStep?: number | Array<number | undefined>;
  /** Note labels displayed on the right side (bottom to top order) */
  scaleLabels?: string[];
  /** Single label spanning all rows on the right side, vertically centered */
  sideLabel?: string;
  /** Active sequence button numbers (1-8) shown in the left column */
  seqButtons?: number[];
}

/** Props for the OxiGridFigure wrapper (adds caption to OxiGridProps) */
interface OxiGridFigureProps extends OxiGridProps {
  /** Caption text displayed below the grid as figcaption */
  caption?: string;
  /** Remove bottom padding so consecutive figures appear connected */
  connected?: boolean;
}

export type { PadData, OxiGridProps, OxiGridFigureProps };

const DEFAULT_STEPS = 16;
const DEFAULT_ROWS = 8;

/** OxiGrid color palette */
const COLOR = {
  PAD_DEFAULT: 'rgb(50,50,50)',
  MARKER: 'rgb(168,85,247)',
  PLAYHEAD: 'rgba(255,255,255,0.7)',
  PLAYHEAD_SHADOW: 'rgba(255,255,255,0.5)',
  LABEL_DARK: 'rgb(20,20,20)',
  LABEL_LIGHT: 'rgba(255,255,255,0.9)',
} as const;

function getPadKey(col: number, row: number) {
  return `${col}-${row}`;
}

function buildPadMap(pads: PadData[]) {
  const map = new Map<string, PadData>();
  for (const pad of pads) {
    map.set(getPadKey(pad.col, pad.row), pad);
  }
  return map;
}

const padCellClass =
  'flex items-center justify-center aspect-square select-none font-mono text-[0.7em] leading-none transition-colors duration-150 rounded-[3px]';

const scaleLabelClass =
  'flex items-center pl-[4px] select-none font-mono text-[0.5em] lg:text-[0.65em] text-zd-subtext whitespace-nowrap';

const seqButtonBaseClass =
  'flex items-center justify-center aspect-square select-none font-mono leading-none rounded-[3px] text-[0.6em]';

const sideLabelClass =
  'flex items-center justify-center select-none font-mono text-[0.5em] lg:text-[0.65em] text-zd-subtext whitespace-nowrap [writing-mode:vertical-rl]';

const seqButtonActiveClass = `${seqButtonBaseClass} bg-[rgb(59,130,246)] text-white`;
const seqButtonInactiveClass = `${seqButtonBaseClass} bg-[rgb(60,60,60)] text-[rgb(120,120,120)]`;

export const OxiGrid = ({
  steps = DEFAULT_STEPS,
  rows = DEFAULT_ROWS,
  pads = [],
  playhead,
  initStep,
  endStep,
  scaleLabels,
  sideLabel,
  seqButtons,
}: OxiGridProps) => {
  const padMap = useMemo(() => buildPadMap(pads), [pads]);
  const seqButtonSet = useMemo(() => new Set(seqButtons ?? []), [seqButtons]);

  const hasSeqButtons = seqButtons && seqButtons.length > 0;
  const hasScaleLabels = scaleLabels && scaleLabels.length > 0;
  const hasRightCol = hasScaleLabels || !!sideLabel;

  // CSS Grid column template: optional seq btn + N step columns + optional label
  // seq btn column is narrower (0.6fr), label column is auto-sized
  const gridTemplateCols = [
    hasSeqButtons ? '0.6fr' : '',
    `repeat(${steps}, minmax(0, 1fr))`,
    hasRightCol ? 'auto' : '',
  ]
    .filter(Boolean)
    .join(' ');

  const gridRows = useMemo(() => {
    const hasPlayhead = playhead !== undefined;
    const initIsArr = Array.isArray(initStep);
    const endIsArr = Array.isArray(endStep);
    const hasMarkers = initStep !== undefined || endStep !== undefined;
    // Bottom marker strip only makes sense when both markers span all rows.
    // When either is an array (per-row), the markers live on the pads themselves.
    const hasMarkerRow = hasMarkers && !initIsArr && !endIsArr;
    const result: ComponentChildren[] = [];

    // Calculate grid row/col positions for sideLabel spanning
    const playheadRowOffset = hasPlayhead ? 1 : 0;
    // Pad rows start after playhead row (1-indexed for CSS grid-row)
    const padRowStart = 1 + playheadRowOffset;
    const padRowEnd = padRowStart + rows;
    // Right-side column number (1-indexed) for sideLabel
    const rightCol = (hasSeqButtons ? 1 : 0) + steps + 1;

    // Playhead row
    if (hasPlayhead) {
      result.push(
        <Fragment key="playhead-row">
          {hasSeqButtons && <div />}
          {Array.from({ length: steps }, (_, c) => (
            <div
              key={`ph-${c}`}
              className="rounded-[1px]"
              style={{
                aspectRatio: `${steps} / 1`,
                backgroundColor: c === playhead ? COLOR.PLAYHEAD : 'transparent',
              }}
            />
          ))}
          {hasRightCol && !sideLabel && <div />}
        </Fragment>,
      );
    }

    // Side label: single cell spanning all pad rows, vertically centered
    if (sideLabel) {
      result.push(
        <div
          key="side-label"
          className={sideLabelClass}
          style={{
            gridColumn: rightCol,
            gridRow: `${padRowStart} / ${padRowEnd}`,
          }}
        >
          {sideLabel}
        </div>,
      );
    }

    // Main pad rows (top to bottom = high to low pitch)
    for (let r = rows - 1; r >= 0; r--) {
      const rowCells: ComponentChildren[] = [];
      const rowInit = initIsArr ? (initStep as Array<number | undefined>)[r] : initStep;
      const rowEnd = endIsArr ? (endStep as Array<number | undefined>)[r] : endStep;

      // Seq button cell
      if (hasSeqButtons) {
        const num = r + 1;
        const isActive = seqButtonSet.has(num);
        rowCells.push(
          <div
            key={`seq-${r}`}
            className={isActive ? seqButtonActiveClass : seqButtonInactiveClass}
          >
            {num}
          </div>,
        );
      }

      // Pad cells
      for (let c = 0; c < steps; c++) {
        const pad = padMap.get(getPadKey(c, r));
        const isPlayhead = hasPlayhead && c === playhead;
        const isInitMarker = rowInit !== undefined && c === rowInit;
        const isEndMarker = rowEnd !== undefined && c === rowEnd;

        let bgColor: string = COLOR.PAD_DEFAULT;
        if (pad?.color) {
          bgColor = pad.color;
        } else if (isInitMarker || isEndMarker) {
          bgColor = COLOR.MARKER;
        }

        const boxShadow = isPlayhead ? `inset 0 0 0 2px ${COLOR.PLAYHEAD_SHADOW}` : undefined;

        rowCells.push(
          <div
            key={`pad-${c}-${r}`}
            className={padCellClass}
            style={{
              backgroundColor: bgColor,
              boxShadow,
              color: pad?.label
                ? isLightColor(bgColor)
                  ? COLOR.LABEL_DARK
                  : COLOR.LABEL_LIGHT
                : undefined,
            }}
          >
            {pad?.label}
          </div>,
        );
      }

      // Scale label cell (per-row labels, not used with sideLabel)
      if (hasScaleLabels) {
        const label = scaleLabels[r] ?? '';
        rowCells.push(
          <div key={`label-${r}`} className={scaleLabelClass}>
            {label}
          </div>,
        );
      }

      result.push(<Fragment key={`row-${r}`}>{rowCells}</Fragment>);
    }

    // Init/End marker row (only rendered when markers span all rows)
    if (hasMarkerRow) {
      result.push(
        <Fragment key="marker-row">
          {hasSeqButtons && <div />}
          {Array.from({ length: steps }, (_, c) => {
            const isInit = typeof initStep === 'number' && c === initStep;
            const isEnd = typeof endStep === 'number' && c === endStep;
            return (
              <div
                key={`mk-${c}`}
                className="rounded-[1px]"
                style={{
                  aspectRatio: `${steps} / 1`,
                  backgroundColor: isInit || isEnd ? COLOR.MARKER : 'transparent',
                }}
              />
            );
          })}
          {hasRightCol && !sideLabel && <div />}
        </Fragment>,
      );
    }

    return result;
  }, [
    rows,
    steps,
    padMap,
    playhead,
    initStep,
    endStep,
    hasSeqButtons,
    hasScaleLabels,
    hasRightCol,
    seqButtonSet,
    scaleLabels,
    sideLabel,
  ]);

  return (
    <div
      role="img"
      aria-label="OXI ONE MKII sequencer grid"
      className={
        'grid w-full gap-[3px] p-[5px] lg:gap-[5px] lg:p-[8px] rounded-lg bg-[rgb(24,24,24)]'
      }
      style={{
        gridTemplateColumns: gridTemplateCols,
      }}
    >
      {gridRows}
    </div>
  );
};

function isLightColor(color: string): boolean {
  const match = color.match(/\d+/g);
  if (!match || match.length < 3) return false;
  const [r, g, b] = match.map(Number);
  return (r! * 299 + g! * 587 + b! * 114) / 1000 > 150;
}

oxi-grid-figure.tsx

import { OxiGrid, type OxiGridFigureProps } from './oxi-grid';

const captionClass = 'text-center text-zd-subtext text-sm mt-vgap-xs';

/**
 * Responsive wrapper for OxiGrid in MDX articles.
 * - screen < lg: width 100%
 * - screen >= lg: centered, max 70%
 * Caption is rendered outside the grid as a figcaption.
 */
const DEFAULT_STEPS = 16;

export const OxiGridFigure = ({ caption, connected, ...gridProps }: OxiGridFigureProps) => {
  const steps = gridProps.steps ?? DEFAULT_STEPS;
  // Scale grid width proportionally when fewer than 16 steps,
  // so cells stay the same size as a 16-step grid
  const gridMaxWidth = steps < DEFAULT_STEPS ? `${(steps / DEFAULT_STEPS) * 100}%` : undefined;

  return (
    <figure
      className={`w-full lg:mx-auto lg:max-w-[70%] pt-vgap-sm ${connected ? 'pb-0' : 'pb-vgap-lg'}`}
      aria-label={caption ? undefined : 'OXI ONE MKII grid diagram'}
    >
      <div style={gridMaxWidth ? { maxWidth: gridMaxWidth, marginInline: 'auto' } : undefined}>
        <OxiGrid {...gridProps} />
      </div>
      {caption && <figcaption className={captionClass}>{caption}</figcaption>}
    </figure>
  );
};

Story Source

oxi-grid.stories.tsx

import { OxiGrid } from './oxi-grid';
import { OxiGridFigure } from './oxi-grid-figure';

/**
 * OxiGrid Component
 *
 * Renders an OXI ONE MKII-style 16x8 pad grid for use in MDX articles.
 * Uses CSS Grid with 1fr columns for auto-expandable sizing.
 * Supports pads with colors/labels, playhead, init/end markers,
 * scale labels, and seq buttons.
 */
export const meta = {
  title: 'MDX/OxiGrid',
};

/**
 * Default empty 16x8 grid — fills available width
 */
export const Default = () => <OxiGrid />;

/**
 * C minor melody with notes, playhead at step 5, and scale labels
 */
export const MelodyPattern = () => (
  <OxiGrid
    steps={16}
    rows={8}
    scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
    playhead={4}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 1, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 2, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 3, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 4, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 6, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 7, row: 1, color: 'rgb(0, 200, 80)' },
      { col: 8, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 10, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 11, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 12, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 14, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 15, row: 7, color: 'rgb(0, 200, 80)' },
    ]}
  />
);

/**
 * Tied notes example: steps 3-5 tied together (darker green = tie continuation)
 */
export const TieExample = () => (
  <OxiGrid
    steps={8}
    rows={8}
    scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 1, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 2, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 3, row: 4, color: 'rgb(0, 120, 50)' },
      { col: 4, row: 4, color: 'rgb(0, 120, 50)' },
      { col: 5, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 6, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 7, row: 0, color: 'rgb(0, 200, 80)' },
    ]}
  />
);

/**
 * Init/End loop markers shown as purple indicators
 */
export const InitEndMarkers = () => (
  <OxiGrid
    steps={16}
    rows={8}
    initStep={2}
    endStep={10}
    scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 2, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 4, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 5, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 7, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 8, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 10, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 13, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 15, row: 1, color: 'rgb(0, 200, 80)' },
    ]}
  />
);

/**
 * Accumulator visualization: orange pads show pitch changes over cycles
 */
export const AccumulatorVisualization = () => (
  <OxiGrid
    steps={16}
    rows={8}
    scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 1, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 2, row: 2, color: 'rgb(230, 140, 20)' },
      { col: 3, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 4, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 5, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 6, row: 3, color: 'rgb(230, 140, 20)' },
      { col: 7, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 8, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 9, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 10, row: 4, color: 'rgb(230, 140, 20)' },
      { col: 11, row: 4, color: 'rgb(0, 200, 80)' },
      { col: 12, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 13, row: 2, color: 'rgb(0, 200, 80)' },
      { col: 14, row: 3, color: 'rgb(230, 140, 20)' },
      { col: 15, row: 4, color: 'rgb(0, 200, 80)' },
    ]}
  />
);

/**
 * Grid with seq buttons column showing active sequences
 */
export const WithSeqButtons = () => (
  <OxiGrid
    steps={16}
    rows={8}
    seqButtons={[1, 3]}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
      { col: 4, row: 3, color: 'rgb(0, 200, 80)' },
      { col: 8, row: 5, color: 'rgb(0, 200, 80)' },
      { col: 12, row: 2, color: 'rgb(0, 200, 80)' },
    ]}
  />
);

/**
 * OxiGrid wrapped in OxiGridFigure for responsive article layout.
 * Caption is rendered as figcaption outside the grid.
 */
export const InFigureWrapper = () => (
  <div style={{ width: 800, border: '1px dashed rgba(255,255,255,0.2)' }}>
    <OxiGridFigure
      steps={16}
      rows={8}
      scaleLabels={['C2', 'D2', 'Eb2', 'F2', 'G2', 'Ab2', 'Bb2', 'C3']}
      playhead={4}
      pads={[
        { col: 0, row: 0, color: 'rgb(0, 200, 80)' },
        { col: 1, row: 2, color: 'rgb(0, 200, 80)' },
        { col: 2, row: 3, color: 'rgb(0, 200, 80)' },
        { col: 3, row: 4, color: 'rgb(0, 200, 80)' },
        { col: 4, row: 2, color: 'rgb(0, 200, 80)' },
        { col: 8, row: 4, color: 'rgb(0, 200, 80)' },
        { col: 12, row: 0, color: 'rgb(0, 200, 80)' },
        { col: 15, row: 7, color: 'rgb(0, 200, 80)' },
      ]}
      caption="Wrapped in OxiGridFigure (responsive container)"
    />
  </div>
);

/**
 * Grid with labels on pads
 */
export const WithLabels = () => (
  <OxiGrid
    steps={16}
    rows={8}
    pads={[
      { col: 0, row: 0, color: 'rgb(0, 200, 80)', label: 'C' },
      { col: 1, row: 1, color: 'rgb(0, 200, 80)', label: 'D' },
      { col: 2, row: 2, color: 'rgb(0, 200, 80)', label: 'E' },
      { col: 3, row: 3, color: 'rgb(0, 200, 80)', label: 'F' },
      { col: 4, row: 2, color: 'rgb(59, 130, 246)', label: 'E' },
      { col: 5, row: 1, color: 'rgb(59, 130, 246)', label: 'D' },
      { col: 6, row: 0, color: 'rgb(59, 130, 246)', label: 'C' },
    ]}
  />
);