Grid-config-svelte Svelte Themes

Grid Config Svelte

Swiss Grid

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/


Table of Contents

  1. What this is, what this isn't
  2. Quick start
  3. How to use it
  4. File map — where everything lives
  5. Key concepts
  6. How to modify common things
  7. The math
  8. Architecture notes
  9. Roadmap

What this is, what this isn't

This is a tool to design one shared grid system across an entire site:

  • One baseline rhythm
  • One row height (absolute, locked)
  • One set of type presets (display-1, display-2, h1, h2, h3, body, caption)
  • One set of section variants (hero, vertical-scroll, horizontal-scroll)

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.


Quick start

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.


How to use it

  1. Grid tab — set the design viewport (e.g., 90 × 56.25 rem = 1440 × 900 px), columns, gutters, margins, and how many rows fit. The baseline derives automatically.
  2. Type tab — tune each preset's row/baseline span, weight, and letter-spacing. Live "Aa" preview at the actual size.
  3. Tools (left toolbar) — V select, T text, R shape. Drag on canvas to drop blocks for sanity-checking.
  4. Block tab — when a block is selected: pick its preset, font, color, italic, alignment.
  5. Export tab — copy CSS, HTML, or JSON. JSON is the LLM-friendly source of truth.

Keyboard

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

File map — where everything lives

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.


Key concepts

The baseline is the atom

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.

Row height is absolute

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.

Type presets are derived

Each preset declares either:

  • A row span (e.g., 2 rows for h1) → line-height = that span in px, snapped to baseline
  • A baseline span (e.g., 3 bl for body) → line-height = that × baseline directly

Then 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.

Section variants share the system

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.

Baseline alignment of text

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.


How to modify common things

Add a new font

  1. Open src/lib/types.ts.
  2. Extend FontFamily (e.g., add 'inter').
  3. Add the stack to FONT_STACKS and a label to FONT_LABELS, append the key to FONT_KEYS.
  4. (Optional) Load it via <link> in src/app.html if it's a web font.

Change a default preset

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 sizes
  • span: how many rows or baselines tall the line-height is
  • weight: 100..900
  • letterSpacing: 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.

Add a new preset slot

  1. Add to DEFAULT_PRESETS in types.ts with a unique key (e.g., mega).
  2. Bump STORAGE_KEY in +page.svelte so existing users get the new default.
  3. The Type tab and Block tab pick it up automatically — no other code needed.

Change export output

src/lib/exportCss.ts has three builders:

  • buildCss(p) — CSS with :root vars, .grid, section variants, preset utilities, sample block classes
  • buildHtml(p) — sample HTML markup
  • buildJson(p) — full system spec for LLM ingestion

Each 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.

Add a new tool

  1. Add the key to the Tool union and the TOOLS array in types.ts.
  2. Handle the new tool in ContentLayer.svelte's onLayerDown (drag-create) and onCreateUp (block factory).
  3. Add a default factory in types.ts if it creates a new block kind.

Add a new section variant

Edit sectionVariants(p) in exportCss.ts to emit the new class. Add a corresponding entry under sections in buildJson.

Style the panel / toolbar / canvas

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 */
}

The math

Row span (handles half-rows correctly)

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.

Resolving a preset

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.

Baseline alignment (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.

Vertical alignment with overflow

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.


Architecture notes

State flow

+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).

Reactivity tricks

  • Editor heights: SvelteMap<id, number> populated by a ResizeObserver per editor. Used to compute middle/bottom alignment offset.
  • Focus-frozen heights: while a contenteditable has focus, height updates are buffered, not applied — this prevents middle/bottom alignment from re-centering on every keystroke.
  • One-shot text init: editor content is set via a Svelte action (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).

Why no SSR-friendly setup

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.

Pointer event ergonomics

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.


Roadmap

Pending features (not yet implemented):

  • Add/remove/rename presets — currently the slot list is fixed
  • Section preview canvas — stacked scrollable preview of all three section variants in one page
  • SVG / image embed — drag-drop / paste markup with object-fit + grid positioning
  • Multi-select + group ops (move/style/delete N blocks)
  • Undo / redo
  • Snap guides — Figma-style alignment lines while dragging
  • Tailwind config export
  • Standalone HTML page export — full document with viewport meta, etc.
  • Background reference image — pin a Figma export underneath for tracing
  • Templates — hero / article / 3-col landing presets

See chat history for prioritisation discussion.

Top categories

Loading Svelte Themes