A visual designer for CSS grid + typography systems.
The deliverable is not a layout — it's a system definition (CSS / JSON) that a real codebase or LLM consumes to render any section consistently. Tune the grid + type scale here, then copy the export into your project.
Live (Tailscale): http://100.78.167.1:5173/
This is a tool to design one shared grid system across an entire site:
display-1, display-2, h1, h2, h3, body, caption)You tune the system on a visual canvas, optionally drop sample blocks to sanity-check it, then export the result. Every section in your real site uses the same exported config — guaranteeing consistency.
This is not a Webflow / Figma replacement. The blocks on canvas are scratch — they help you see the system working, but they aren't the deliverable.
git clone https://github.com/AkiSato49/swiss-grid
cd swiss-grid
npm install
npm run dev -- --host # --host exposes on LAN / Tailscale
Then open the URL Vite prints. The first-time guide overlay walks you through everything; press ? any time to reopen it.
90 × 56.25 rem = 1440 × 900 px), columns, gutters, margins, and how many rows fit. The baseline derives automatically.V select, T text, R shape. Drag on canvas to drop blocks for sanity-checking.| Key | Action |
|---|---|
V T R |
Switch tool |
? H |
Toggle the guide overlay |
. |
Toggle toolbar |
, |
Toggle right panel |
| Arrow keys | Nudge selected block |
| Shift + arrows | Resize selected block |
Cmd/Ctrl + D |
Duplicate selected block |
| Delete / Backspace | Delete selected block |
| Double-click block | Enter text edit mode |
| Escape | Exit edit / deselect |
src/
├── routes/
│ ├── +layout.svelte Layout shell (loads global CSS)
│ └── +page.svelte Top-level state + composes all components
├── lib/
│ ├── types.ts ★ Type definitions, default presets, geometry math
│ ├── exportCss.ts ★ CSS / HTML / JSON export builders
│ ├── Toolbar.svelte Left tool palette (Select / Text / Shape)
│ ├── Panel.svelte Right tabbed panel (Grid / Type / Block / Layers / Export)
│ ├── ScrubInput.svelte Figma-style drag-to-scrub number input
│ ├── ContentLayer.svelte Canvas: blocks, drag-create, move, resize, edit
│ ├── Grid.svelte Visual grid overlays (cols / rows / baseline)
│ ├── Guide.svelte First-time tutorial overlay (also `?` button)
│ ├── index.ts (boilerplate, unused)
│ └── assets/ Static assets (favicon, etc.)
├── app.css Global custom-property defaults + reset
└── app.html SvelteKit template
The two files marked ★ are where most editing happens. Everything else is presentation.
baseline (in rem, e.g. 0.5rem = 8px) is the smallest vertical unit. Every vertical measurement — line-heights, row heights, gutters — is an integer multiple of it. Snap to the grid is enforced by construction, not checked after.
Defined as blPerRow × baseline (e.g., 12 × 8px = 96px). It does not depend on the actual viewport — it's anchored to a configurable design viewport. Long-scroll and horizontal-scroll sections reuse the same row height; they just have more rows or columns.
Each preset declares either:
2 rows for h1) → line-height = that span in px, snapped to baseline3 bl for body) → line-height = that × baseline directlyThen font-size = lineHeight × fillRatio (a global ratio, default 0.7).
So the entire type scale is a function of (baseline, rowHeight, rowGutter, fillRatio) plus per-preset (span, weight, letterSpacing). Edit any of those, every block using the preset updates.
The export emits three section classes:
.section-hero { height: <viewportHeight>; }
.section-scroll { --section-rows: 18; min-height: ...; }
.section-horizontal { --section-cols: 36; width: ...; } /* GSAP-pinned */
All three use the same .grid rules, so columns / row heights / gutters / baseline align perfectly across sections.
Every text block has a computed padding-top so its first text baseline lands on a baseline grid line. The formula uses Canvas font metrics (fontBoundingBoxAscent / fontBoundingBoxDescent) to find where the baseline sits inside the line box, then snaps to the nearest grid line above or below. Subsequent lines auto-align because line-height is always a baseline multiple.
src/lib/types.ts.FontFamily (e.g., add 'inter').FONT_STACKS and a label to FONT_LABELS, append the key to FONT_KEYS.<link> in src/app.html if it's a web font.Edit DEFAULT_PRESETS in src/lib/types.ts. Each entry:
{ key: 'h1', label: 'H1', unit: 'row', span: 2, weight: 700, letterSpacing: -0.02 }
unit: 'row' for big sizes, 'bl' for body-ish sizesspan: how many rows or baselines tall the line-height isweight: 100..900letterSpacing: em (negative tightens for big sizes)Note: existing user state is in localStorage under swiss-grid-state-v2, which preserves the user's edits. To force a default reset, bump the storage key version in src/routes/+page.svelte.
DEFAULT_PRESETS in types.ts with a unique key (e.g., mega).STORAGE_KEY in +page.svelte so existing users get the new default.src/lib/exportCss.ts has three builders:
buildCss(p) — CSS with :root vars, .grid, section variants, preset utilities, sample block classesbuildHtml(p) — sample HTML markupbuildJson(p) — full system spec for LLM ingestionEach is pure — takes an ExportParams object, returns a string. To add a Tailwind / SCSS / Style Dictionary output, add a new builder there and a tab in Panel.svelte's export section.
Tool union and the TOOLS array in types.ts.ContentLayer.svelte's onLayerDown (drag-create) and onCreateUp (block factory).types.ts if it creates a new block kind.Edit sectionVariants(p) in exportCss.ts to emit the new class. Add a corresponding entry under sections in buildJson.
All component styles are local (<style> blocks inside each .svelte file). Global tokens live in src/app.css:
:root {
--col-color: rgba(255, 0, 0, 0.08); /* column overlay */
--row-color: rgba(0, 160, 80, 0.06); /* row overlay */
--baseline-color: rgba(0, 100, 255, 0.18); /* baseline lines */
}
span(x) = x × rowHeight + (x − 1) × rowGutter if x is integer
= x × rowHeight + floor(x) × rowGutter if x is fractional
Implemented in rowSpanPx() in types.ts. Half-row sizes (e.g., 1.5×) cross one full gutter.
raw = preset.unit === 'row'
? rowSpanPx(preset.span, rowHeightPx, rowGutterPx)
: preset.span × baselinePx
lhBl = max(1, round(raw / baselinePx)) // snap to baseline multiple
lhPx = lhBl × baselinePx
sizePx = lhPx × fillRatio
resolvePreset() in types.ts.
padding-top per text block)contentArea = ascent + descent // from Canvas measureText
halfLeading = (lhPx − contentArea) / 2
blInLb = halfLeading + ascent // baseline position within line box
blockTop = (rowStart − 1) × (rowHeight + rowGutter)
total = blockTop + desiredOffset + blInLb
k = vertAlign === 'top' ? ceil(total / baseline) : round(total / baseline)
paddingTop = max(0, k × baseline − blockTop − blInLb)
blockPaddingTop() in ContentLayer.svelte.
editorH = min(blockHeight, measuredHeight)
desired = top → 0
middle → (blockH − editorH) / 2
bottom → blockH − editorH
Capping at blockHeight means content overflowing the block defaults to top alignment — text starts at top, overflows below, no resize jitter.
+page.svelte owns all top-level state (grid config, presets, blocks, viewport, fillRatio). It passes these down via Svelte 5's bind: directive, so child components mutate parent state directly. No store, no context — the runes-bindable model is enough at this scale.
State persists to localStorage under swiss-grid-state-v2 on every change (debounced by Svelte's effect batching).
SvelteMap<id, number> populated by a ResizeObserver per editor. Used to compute middle/bottom alignment offset.use:initEditor) that fires once on mount. The template never re-renders the text, so cursor position is preserved through every keystroke (this was the cause of the original "writes backwards" bug).The canvas is fundamentally a client-only tool. localStorage, Canvas, ResizeObserver are all client APIs. SvelteKit's defaults work fine with appropriate typeof window !== 'undefined' guards in a couple places.
window.addEventListener('pointermove'/'pointerup') is used during drag operations rather than setPointerCapture. The latter doesn't always interact well with Svelte's event delegation, especially for pointerup outside the original element.
Pending features (not yet implemented):
See chat history for prioritisation discussion.