Stample Layout Redesign — Notepat Comparison Report #
Date: January 29, 2025
Author: Generated by Copilot
Status: ✅ IMPLEMENTED
Implementation Summary #
The responsive layout system has been implemented in stample.mjs:
Key Changes #
- Added
getLayoutMetrics()function for centralized layout computation - Added
getCachedLayout()for performance optimization - Updated
boot(),paint(),genPats(),layoutBitmapUI(), andact()to use layout metrics - Removed all hardcoded layout constants (
labelHeight,menuHeight,bitmapColumnWidth, etc.)
Layout Modes #
- Recording Mode: Full-screen centered preview with responsive stop button
- Normal Mode (Bitmap Beside): Strip buttons on left, bitmap column on right (when
width >= 280) - Normal Mode (Bitmap Overlay): Full-width strips, bitmap overlaid in corner (portrait/narrow)
Responsive Thresholds #
COMPACT_HEIGHT = 160— triggers compact mode with smaller controlsNARROW_WIDTH = 180— triggers narrow modeBITMAP_BESIDE_MIN_WIDTH = 280— minimum width for side-by-side layout
Table of Contents #
- Executive Summary
- Current Stample Problems
- Notepat Layout Architecture
- Proposed Stample Redesign
- Implementation Plan
Executive Summary #
Stample's current layout is ad-hoc with hardcoded values that don't adapt well to different screen sizes. Elements overlap, the bitmap preview column steals space awkwardly, and the BPM/controls have no proper home.
Notepat's layout system is sophisticated and responsive:
- Uses a centralized metrics function (
getButtonLayoutMetrics()) that computes all dimensions - Has multiple layout modes (compact, split, horizontal, vertical)
- Uses caching to avoid recalculating on every frame
- Calculates reserved zones for different UI elements
- Responds intelligently to screen aspect ratio
Current Stample Problems #
1. Hardcoded Layout Constants #
// Current stample.mjs - hardcoded values scattered throughout
const menuHeight = 32;
const labelHeight = 24;
const bitmapPreviewSize = 120;
const bitmapPreviewMargin = 6;
const bitmapColumnWidth = 148; // ❌ Fixed column steals space
Problems:
bitmapColumnWidth = 148is always reserved, even when no bitmap exists- Button widths calculated as
Math.max(32, screen.width - bitmapColumnWidth)— awkward subtraction - No consideration for portrait vs landscape
- No adaptation for small screens
2. Split Layout is Inflexible #
Current approach creates a rigid 2-column layout:
┌────────────────────────┬────────────────┐
│ Header (24px) │ │
├────────────────────────┤ Bitmap │
│ │ Preview │
│ Vertical │ (148px wide) │
│ Strip Buttons │ │
│ ├────────────────┤
│ │ Paint Button │
│ │ Loop Button │
├────────────────────────┴────────────────┤
│ Record Button (64px wide) │
└─────────────────────────────────────────┘
Issues:
- Bitmap column is always 148px even with small/no bitmap
- Strip buttons get compressed on narrow screens
- No reflow for portrait mode
- Wasted space when bitmap is smaller than column width
3. No Responsive Breakpoints #
The genPats() function is simplistic:
function genPats({ screen, ui }) {
for (let i = 0; i < pats; i += 1) {
const strip = (screen.height - menuHeight - labelHeight) / pats;
const width = Math.max(32, screen.width - bitmapColumnWidth);
// ...
}
}
- No compact mode for small screens
- No consideration of whether bitmap exists
- Width calculation is subtraction-based, not allocation-based
4. Recording Mode Layout is Separate #
Recording mode has its own layout logic that duplicates calculations:
if (isRecording) {
const margin = 20;
const stopBtnH = 40;
const livePreviewW = Math.min(screen.width - margin * 2, 300);
// ... completely different coordinate system
}
Notepat Layout Architecture #
1. Centralized Metrics Function #
Notepat uses getButtonLayoutMetrics() (lines 1871-2140) to compute ALL layout values:
function getButtonLayoutMetrics(screen, options) {
const reservedSide = sidePanelWidth || 0;
const hudReserved = SECONDARY_BAR_BOTTOM;
// Calculate usable space
const usableWidth = max(0, screen.width - reservedSide);
// Detect mode based on screen size
const compactMode = screen.height < 200;
const isLandscape = screen.width > screen.height;
if (compactMode && isLandscape) {
// Split layout: buttons on sides, piano in center
return { splitLayout: true, ... };
}
// Standard layout calculations
return {
buttonWidth,
buttonHeight,
topButtonY,
totalRows,
buttonsPerRow,
margin,
bottomPadding,
hudReserved,
...
};
}
2. Layout Caching #
Notepat caches layout to avoid recomputation:
function getCachedLayout(screen, options) {
const key = [
screen.width, screen.height,
songMode ? 1 : 0,
pictureOverlay ? 1 : 0,
// ... all relevant state
].join("|");
if (layoutCache.key === key && layoutCache.layout) {
return layoutCache;
}
// Recompute and cache
layoutCache = { key, layout, midiMetrics };
return layoutCache;
}
3. Responsive Breakpoints #
Notepat has intelligent breakpoints:
// Compact mode for DAW/small screens
const compactMode = screen.height < 200;
const isLandscape = screen.width > screen.height;
// Check if mini inputs fit horizontally vs vertically
const horizontalSpaceForMini = usableWidth - gridWidthEstimate - pianoWidth > 10;
const narrowVerticalSpace = !horizontalSpaceForMini &&
usableWidth - gridWidthEstimate - rotatedPianoWidth > 4;
// Decide layout style
const showMiniInputs = miniInputsEnabled && canFitMini;
4. Dynamic Reserved Zones #
Notepat calculates reserved zones dynamically:
const hudReserved = SECONDARY_BAR_BOTTOM; // Top bar
const trackHeight = songMode ? TRACK_HEIGHT : 0; // Optional track
const bottomPadding = pictureOverlay
? baseBottomPadding
: songMode
? baseBottomPadding + badgeMetrics.height + MIDI_BADGE_MARGIN + aliasPadding
: baseBottomPadding;
5. Aspect Ratio Constraints #
Notepat keeps button aspect ratios reasonable:
// Keep aspect ratio between 0.5 and 2.0
const aspectRatio = buttonWidth / buttonHeight;
if (aspectRatio > 1.5) {
buttonWidth = floor(buttonHeight * 1.5);
} else if (aspectRatio < 0.67) {
buttonHeight = floor(buttonWidth * 1.5);
}
Proposed Stample Redesign #
New Layout Zones #
┌─────────────────────────────────────────┐
│ TOP BAR (24px) - Mode indicators, pats │
├─────────────────────────────────────────┤
│ │
│ MAIN AREA │
│ (Responsive: bitmap + strips or just │
│ strips when no bitmap) │
│ │
├─────────────────────────────────────────┤
│ BOTTOM BAR (32px) - Record, Loop, Paint │
└─────────────────────────────────────────┘
Responsive Modes #
Mode 1: Portrait / No Bitmap #
┌────────────────────┐
│ pats: 4 pat→ │ ← Top bar
├────────────────────┤
│ │
│ STRIP BUTTONS │ ← Full width
│ (vertical stack) │
│ │
├────────────────────┤
│ [Record] [Loop] │ ← Bottom bar
└────────────────────┘
Mode 2: Landscape with Bitmap #
┌───────────────────────────────────────┐
│ pats: 4 pat→ │
├─────────────────────────┬─────────────┤
│ │ ┌─────────┐ │
│ STRIP BUTTONS │ │ BITMAP │ │
│ (fills remaining) │ │ PREVIEW │ │
│ │ └─────────┘ │
│ │ [Loop][Pnt] │
├─────────────────────────┴─────────────┤
│ [Record] waveform │
└───────────────────────────────────────┘
Mode 3: Compact / Small Screen #
┌─────────────────────┐
│ 4 pats [rec] [loop]│ ← Combined bar
├─────────────────────┤
│ │
│ STRIP BUTTONS │
│ (maximize space) │
│ │
├─────────────────────┤
│ [bitmap preview] │ ← Only if exists
└─────────────────────┘
New Layout Constants #
// Layout zones
const TOP_BAR_HEIGHT = 24;
const BOTTOM_BAR_HEIGHT = 36;
const MIN_STRIP_WIDTH = 48;
const MIN_STRIP_HEIGHT = 32;
// Bitmap preview sizing
const BITMAP_PREVIEW_MIN = 64;
const BITMAP_PREVIEW_MAX = 160;
const BITMAP_PREVIEW_MARGIN = 8;
// Responsive breakpoints
const COMPACT_HEIGHT_THRESHOLD = 180;
const NARROW_WIDTH_THRESHOLD = 200;
const SHOW_BITMAP_BESIDE_THRESHOLD = 320; // Width needed to show bitmap beside strips
New Metrics Function #
function getStampleLayoutMetrics(screen, { hasBitmap = false, pats = 4 } = {}) {
const isCompact = screen.height < COMPACT_HEIGHT_THRESHOLD;
const isNarrow = screen.width < NARROW_WIDTH_THRESHOLD;
const isLandscape = screen.width > screen.height;
// Calculate available space
const topReserved = isCompact ? 20 : TOP_BAR_HEIGHT;
const bottomReserved = isCompact ? 28 : BOTTOM_BAR_HEIGHT;
const availableHeight = screen.height - topReserved - bottomReserved;
// Decide if bitmap goes beside or below strips
const bitmapBeside = hasBitmap && isLandscape && screen.width >= SHOW_BITMAP_BESIDE_THRESHOLD;
// Calculate bitmap preview size (scales with available space)
let bitmapSize = 0;
let bitmapColumnWidth = 0;
if (hasBitmap) {
if (bitmapBeside) {
// Beside: column to the right
bitmapSize = Math.min(BITMAP_PREVIEW_MAX, Math.floor(availableHeight * 0.6));
bitmapSize = Math.max(BITMAP_PREVIEW_MIN, bitmapSize);
bitmapColumnWidth = bitmapSize + BITMAP_PREVIEW_MARGIN * 2;
} else {
// Below or overlaid: smaller thumbnail
bitmapSize = Math.min(80, Math.floor(screen.width * 0.3));
}
}
// Strip button dimensions
const stripWidth = screen.width - bitmapColumnWidth;
const stripHeight = Math.floor(availableHeight / pats);
// Button positions
const recordButtonWidth = isCompact ? 48 : 72;
const recordButtonHeight = isCompact ? 24 : 32;
return {
// Mode flags
isCompact,
isNarrow,
isLandscape,
bitmapBeside,
// Zones
topReserved,
bottomReserved,
availableHeight,
// Strip buttons
stripWidth,
stripHeight,
stripTop: topReserved,
stripLeft: 0,
// Bitmap
hasBitmap,
bitmapSize,
bitmapColumnWidth,
bitmapX: bitmapBeside ? screen.width - bitmapColumnWidth + BITMAP_PREVIEW_MARGIN : null,
bitmapY: bitmapBeside ? topReserved + 8 : null,
// Bottom bar
bottomBarY: screen.height - bottomReserved,
recordButtonWidth,
recordButtonHeight,
};
}
Recording Mode Integration #
Instead of separate layout code, recording mode becomes a layout flag:
function getStampleLayoutMetrics(screen, { isRecording = false, ... }) {
if (isRecording) {
// Full-screen recording preview with centered stop button
const previewMargin = 20;
const stopButtonHeight = 48;
return {
isRecording: true,
previewX: previewMargin,
previewY: TOP_BAR_HEIGHT + previewMargin,
previewWidth: screen.width - previewMargin * 2,
previewHeight: screen.height - TOP_BAR_HEIGHT - stopButtonHeight - previewMargin * 3,
stopButtonX: (screen.width - 120) / 2,
stopButtonY: screen.height - stopButtonHeight - previewMargin,
stopButtonWidth: 120,
stopButtonHeight: stopButtonHeight,
};
}
// ... normal layout
}
Implementation Plan #
Phase 1: Extract Layout Metrics #
- Create
getStampleLayoutMetrics()function - Replace hardcoded values with metrics calls
- Add layout caching like notepat
Phase 2: Refactor genPats() #
- Use metrics for button positioning
- Remove
bitmapColumnWidthsubtraction - Add responsive strip sizing
Phase 3: Unify Recording Mode #
- Move recording layout into metrics function
- Remove duplicate coordinate calculations
- Use same
paint()function with mode flag
Phase 4: Add Responsive Breakpoints #
- Implement compact mode
- Implement portrait vs landscape switching
- Implement bitmap-beside vs bitmap-below
Phase 5: Polish #
- Smooth transitions between modes
- Test on various screen sizes
- Ensure touch targets are sufficient
Key Takeaways from Notepat #
| Feature | Notepat | Current Stample | Proposed Stample |
|---|---|---|---|
| Layout metrics | Centralized function | Scattered hardcodes | Centralized function |
| Layout caching | ✅ Yes | ❌ No | ✅ Yes |
| Aspect ratio limits | ✅ Yes | ❌ No | ✅ Yes |
| Compact mode | ✅ Yes (split) | ❌ No | ✅ Yes |
| Reserved zones | ✅ Dynamic | ❌ Fixed | ✅ Dynamic |
| Landscape adaptation | ✅ Yes | ❌ Partial | ✅ Yes |
| Button size limits | min=20, max=64 | min=32 only | min/max |
| Recording mode | N/A | Separate code path | Integrated |
Files to Modify #
| File | Changes |
|---|---|
| stample.mjs | Complete layout refactor |
Related Files #
- notepat.mjs — Reference implementation
- clock.mjs — Another piece with responsive layout
Summary #
Stample needs a complete layout overhaul using notepat's architectural patterns:
- Centralized metrics — One function computes all layout values
- Responsive modes — Detect screen size and adapt
- Dynamic allocation — Don't subtract, allocate
- Caching — Don't recompute every frame
- Unified recording — Same system, different mode flag
The result will be a clean, responsive layout that works on phones, tablets, DAWs, and desktops without overlapping elements or wasted space.