{"id":47921888,"url":"https://github.com/baselashraf81/layout-sans","last_synced_at":"2026-04-05T07:00:55.407Z","repository":{"id":347925806,"uuid":"1195664999","full_name":"BaselAshraf81/layout-sans","owner":"BaselAshraf81","description":"Pure TypeScript Flex/Grid layout engine — no DOM, no WASM. Full interactive text stack: selection, clipboard, Ctrl+F search, links, and screen-reader a11y. Works in Node, Bun, Deno, Cloudflare Workers, and the browser.","archived":false,"fork":false,"pushed_at":"2026-04-04T04:17:13.000Z","size":12906,"stargazers_count":45,"open_issues_count":0,"forks_count":2,"subscribers_count":1,"default_branch":"main","last_synced_at":"2026-04-04T06:25:58.913Z","etag":null,"topics":["canvas","engine","pretext"],"latest_commit_sha":null,"homepage":"https://baselashraf81.github.io/layout-sans/demo/interactive-text.html","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/BaselAshraf81.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":".github/FUNDING.yml","license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":null,"patreon":null,"open_collective":null,"ko_fi":"baselashraf","tidelift":null,"community_bridge":null,"liberapay":null,"issuehunt":null,"lfx_crowdfunding":null,"polar":null,"buy_me_a_coffee":null,"thanks_dev":null,"custom":null}},"created_at":"2026-03-29T23:55:14.000Z","updated_at":"2026-04-04T05:46:24.000Z","dependencies_parsed_at":null,"dependency_job_id":"54667619-a035-4acd-a350-92d6eb208d35","html_url":"https://github.com/BaselAshraf81/layout-sans","commit_stats":null,"previous_names":["baselashraf81/layout-sans"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/BaselAshraf81/layout-sans","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BaselAshraf81%2Flayout-sans","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BaselAshraf81%2Flayout-sans/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BaselAshraf81%2Flayout-sans/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BaselAshraf81%2Flayout-sans/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/BaselAshraf81","download_url":"https://codeload.github.com/BaselAshraf81/layout-sans/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/BaselAshraf81%2Flayout-sans/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31427386,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-05T02:22:46.605Z","status":"ssl_error","status_checked_at":"2026-04-05T02:22:33.263Z","response_time":75,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["canvas","engine","pretext"],"created_at":"2026-04-04T06:13:05.745Z","updated_at":"2026-04-05T07:00:55.400Z","avatar_url":"https://github.com/BaselAshraf81.png","language":"TypeScript","funding_links":["https://ko-fi.com/baselashraf","https://ko-fi.com/V7V01X2WY5"],"categories":[],"sub_categories":[],"readme":"# LayoutSans\n\n**CSS Flex/Grid layout without the browser. No DOM. No WASM.**\n\n[![npm](https://img.shields.io/npm/v/layout-sans)](https://www.npmjs.com/package/layout-sans)\n[![license](https://img.shields.io/badge/license-MIT-blue)](LICENSE)\n[![bundle size](https://img.shields.io/badge/gzipped-%3C17kB-green)](https://bundlephobia.com/package/layout-sans)\n\n---\n\n🚀 **[Live Demo](https://baselashraf81.github.io/layout-sans/demo/interactive-text.html)** — 100k-item benchmark + interactive text selection, search, and links\n\nA pure TypeScript 2D layout engine. Give it a tree of boxes with flex/grid rules; get back exact pixel positions for every box. Works in Node, Bun, Deno, Cloudflare Workers, browser — anything that runs JS.\n\n**v0.2** adds a full interactive text stack on top of the pure-canvas renderer: text selection, clipboard copy, Ctrl+F search, hyperlinks, and screen-reader accessibility — with zero visible DOM layout and O(viewport) DOM node count regardless of total items.\n\n---\n\n## Why\n\n- **The browser is a constraint, not a requirement.** `getBoundingClientRect` forces synchronous reflows. For server-rendered layouts, virtual lists, canvas renderers, and PDF engines, the DOM is overhead you don't need.\n- **Yoga is great, but it ships WASM.** That is 300+ kB before your first layout call, requires async initialization, and does not run everywhere.\n- **LayoutSans is the missing layer after [Pretext](https://github.com/chenglou/pretext).** Pretext tells you *how big* text is. LayoutSans tells you *where everything goes*. Together they replace browser layout with pure math.\n\n---\n\n## Install\n\n```sh\nnpm install layout-sans\nnpm install @chenglou/pretext   # peer dep for text nodes and v0.2 interaction\n```\n\n---\n\n## Comparison\n\n| | **LayoutSans** | DOM layout | Yoga WASM |\n|---|:---:|:---:|:---:|\n| 100 boxes | **0.27 ms** | 8.0 ms | 0.80 ms |\n| 10,000 boxes | **4.82 ms** | 800 ms | 8.0 ms |\n| 100,000 var-height | **46 ms** | crashes | 85 ms |\n| buildIndex() at 100k | **\u003c 15 ms** | — | — |\n| Hit-test query (R-Tree) | **\u003c 0.5 ms** | — | — |\n| Sub-glyph cursor resolve | **\u003c 0.1 ms** | — | — |\n| Bundle size | **~17 kB gz** | browser only | 300+ kB gz |\n| Node / Bun / Deno | ✅ | ❌ | WASM only |\n| Cloudflare Workers | ✅ | ❌ | ❌ |\n| Async init required | none | ❌ | ✅ |\n| Zero dependencies | ✅ | — | ❌ |\n\n---\n\n## Quick start\n\n```ts\nimport { createLayout } from 'layout-sans'\n\nconst boxes = createLayout({\n  type: 'flex', direction: 'row', width: 800, height: 600, gap: 16,\n  children: [{ type: 'box', flex: 1 }, { type: 'box', width: 240 }],\n}).compute()\n\n// [\n//   { nodeId: '0',   x: 0,   y: 0, width: 800, height: 600 },\n//   { nodeId: '0.0', x: 0,   y: 0, width: 544, height: 600 },\n//   { nodeId: '0.1', x: 560, y: 0, width: 240, height: 600 },\n// ]\n```\n\n---\n\n## v0.2 interactive text\n\n```ts\nimport { createLayout, InteractionBridge, attachMouseHandlers,\n         paintSelection, paintSearchHighlights, paintFocusRing } from 'layout-sans'\nimport * as pretext from '@chenglou/pretext'\n\n// 1. Wait for web fonts — glyph widths are read at compute() time.\nawait document.fonts.ready\n\n// 2. Build engine + spatial index\nconst engine = createLayout(root).usePretext(pretext)\nconst boxes  = engine.compute()\nawait engine.buildIndex()\n\n// 3. Mount bridge (clipboard, search, shadow a11y tree)\nconst bridge = new InteractionBridge(canvas, engine, {\n  searchUI: true,\n  onScrollTo:        (y) =\u003e { scrollY = y; repaint() },\n  requestRepaint:    repaint,\n  onSelectionChange: (text) =\u003e console.log('selected:', text),\n})\n\n// 4. Attach mouse handlers (selection drag, link click, double-click word-select)\nconst detach = attachMouseHandlers({ canvas, engine, getScrollY: () =\u003e scrollY, requestRepaint: repaint })\n\n// 5. RAF loop — paint canvas, then sync bridge\nfunction loop() {\n  paintCanvasFrame()\n  const sel = engine.selection.get()\n  if (sel) paintSelection(ctx, sel, recordMap, engine.textLineMap,\n                          engine.getOrderedTextNodeIds(), scrollY, CH, '#6c7aff55')\n  if (bridge.search.isOpen)\n    paintSearchHighlights(ctx, bridge.search.matches, bridge.search.activeIndex,\n                          scrollY, CH, 'rgba(255,220,0,.4)', 'rgba(255,160,0,.7)')\n  bridge.sync(scrollY)   // always AFTER painting\n  requestAnimationFrame(loop)\n}\n```\n\n---\n\n## v0.2 requirements\n\n### 1. Wait for web fonts before `engine.compute()`\n\n`engine.compute()` reads real glyph widths via `ctx.measureText`. If the fonts are still downloading, widths are computed against the system fallback font and stored incorrectly in `textLineMap`. Selection rects and search highlights will land at shifted positions when the real font paints.\n\n```js\nawait document.fonts.ready     // module context\ndocument.fonts.ready.then(initEngine)  // non-async context\n```\n\n### 2. `outline: none` on the canvas element\n\nWhen the canvas has `tabindex=\"0\"` and a parent has `overflow: hidden`, the browser's focus ring appears as an inset border, misaligning `getBoundingClientRect()` and every subsequent hit-test.\n\n```css\ncanvas { outline: none; }\n```\n\n### 3. `preventScroll: true` on `.focus()` calls inside the canvas-wrap\n\n`overflow: hidden` creates an implicit scroll container. Any `.focus()` call without this flag can silently scroll the container, drifting the canvas coordinate system.\n\n### 4. Canvas DPR setup\n\n```js\nconst dpr = Math.min(window.devicePixelRatio || 1, 2)\ncanvas.width  = containerWidth  * dpr\ncanvas.height = containerHeight * dpr\ncanvas.style.width  = containerWidth  + 'px'\ncanvas.style.height = containerHeight + 'px'\nctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n```\n\n### 5. `bridge.sync()` after painting, never before\n\nCalling `sync()` before painting can trigger a layout recalculation that shifts the canvas position before `getBoundingClientRect()` is read.\n\n---\n\n## API reference\n\n### Core\n\n#### `createLayout(root, options?)`\n\n```ts\nconst engine = createLayout(root, { width?: number, height?: number })\nengine.usePretext(pretextModule)  // chainable; call before compute()\nconst boxes = engine.compute()    // BoxRecord[]\n```\n\n#### `BoxRecord`\n\n```ts\ninterface BoxRecord {\n  nodeId:       string\n  x:            number\n  y:            number\n  width:        number\n  height:       number\n  nodeType:     string    // 'text' | 'heading' | 'link' | 'box' | ...\n  textContent?: string\n  href?:        string\n  target?:      string\n}\n```\n\n---\n\n### Interactive (v0.2+)\n\n#### `engine.buildIndex()`\n\nBuilds the packed R-Tree spatial index. Call once after `compute()`. Returns a `Promise`. Safe to schedule via `requestIdleCallback`.\n\n#### `new InteractionBridge(canvas, engine, options?)`\n\n```ts\ninterface InteractionOptions {\n  searchUI?:             boolean      // default true\n  onLinkClick?:          (href: string, target: string) =\u003e boolean\n  onSelectionChange?:    (text: string) =\u003e void\n  onScrollTo?:           (y: number) =\u003e void\n  requestRepaint?:       () =\u003e void\n}\n\nbridge.sync(scrollY)   // call every frame after painting\nbridge.rebuild()       // call after engine.compute() is re-run\nbridge.destroy()       // call on unmount\n```\n\n#### `attachMouseHandlers(opts)`\n\n```ts\nconst detach = attachMouseHandlers({\n  canvas,\n  engine,\n  getScrollY:         () =\u003e number,\n  getContentOffsetX?: () =\u003e number,   // default 0\n  requestRepaint:     () =\u003e void,\n  onLinkClick?:       (href, target) =\u003e boolean,\n})\ndetach()  // removes all listeners\n```\n\n#### Paint helpers\n\nCall all three **before** drawing text glyphs so highlights sit beneath them.\n\n```ts\npaintSelection(ctx, sel, recordMap, textLineMap, orderedIds, scrollY, viewportH, color)\npaintSearchHighlights(ctx, matches, activeIndex, scrollY, viewportH, inactiveColor, activeColor)\npaintFocusRing(ctx, record, scrollY, color)\n```\n\n#### `engine.selection`\n\n```ts\nengine.selection.get()\nengine.selection.onChange(fn)          // returns unsubscribe fn\nengine.setSelection(startId, startChar, endId, endChar)\nengine.clearSelection()\nawait engine.copySelectedText()        // writes to OS clipboard\n```\n\n#### `bridge.search`\n\n```ts\nbridge.search.openPanel()\nbridge.search.search(query, { caseSensitive?, wholeWord? })\nbridge.search.nextMatch() / prevMatch() / goToMatch(index)\nbridge.search.closePanel()\nbridge.search.isOpen       // boolean\nbridge.search.matches      // SearchMatch[]\nbridge.search.activeIndex  // number\n```\n\n#### `engine.getOrderedTextNodeIds()`\n\nAll text/heading node IDs in document order. Pass to `paintSelection` and use for select-all.\n\n#### `engine.extractText()`\n\nFull plain text of the layout tree in document order.\n\n---\n\n### Node types\n\n#### `FlexNode`\n\n```ts\n{\n  type: 'flex'\n  direction?: 'row' | 'column'\n  gap?: number; rowGap?: number; columnGap?: number\n  justifyContent?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'\n  alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch'\n  wrap?: boolean\n  width?: number; height?: number\n  padding?: number; paddingTop?: number; paddingRight?: number; paddingBottom?: number; paddingLeft?: number\n  margin?: number; marginTop?: number; marginRight?: number; marginBottom?: number; marginLeft?: number\n  children?: Node[]\n}\n```\n\nFlex children may add: `flex`, `flexShrink`, `flexBasis`, `alignSelf`.\n\n#### `BoxNode` · `TextNode` · `HeadingNode` · `LinkNode`\n\n```ts\n{ type: 'box', width?: number, height?: number, flex?: number }\n\n{\n  type: 'text'\n  content: string\n  font?: string        // CSS font string — must match the loaded face\n  lineHeight?: number\n  width?: number\n}\n\n{\n  type: 'heading'\n  level: 1 | 2 | 3 | 4 | 5 | 6\n  content: string; font?: string; lineHeight?: number; width?: number\n}\n\n{\n  type: 'link'\n  href: string\n  target?: '_blank' | '_self' | '_parent' | '_top'\n  rel?: string         // auto-set to 'noopener noreferrer' when target='_blank'\n  aria?: { label?: string }\n  children?: Node[]\n}\n```\n\n#### `GridNode`\n\n```ts\n{\n  type: 'grid'\n  columns?: number; rows?: number\n  gap?: number; rowGap?: number; columnGap?: number\n  children?: Node[]\n}\n```\n\n#### `AbsoluteNode`\n\n```ts\n{\n  type: 'absolute'\n  top?: number; right?: number; bottom?: number; left?: number\n  width?: number; height?: number\n  children?: Node[]\n}\n```\n\n#### `MagazineNode`\n\n```ts\n{\n  type: 'magazine'\n  columnCount: number\n  columnGap?: number\n  content?: string\n  children?: TextNode[]\n  font?: string; lineHeight?: number\n  width: number; height?: number\n}\n```\n\n---\n\n## Performance budget (v0.2, 100,000 items, Chrome 120, M1 MacBook Pro)\n\n| Metric | Budget |\n|---|---|\n| `engine.compute()` | \u003c 5 ms |\n| `engine.buildIndex()` | \u003c 15 ms (idle callback) |\n| Mousemove hit-test (R-Tree) | \u003c 0.5 ms |\n| Sub-glyph char resolution | \u003c 0.1 ms |\n| Selection repaint | \u003c 1 ms |\n| `bridge.sync()` per frame | \u003c 2 ms |\n| DOM node count total | ≤ 700 |\n| Canvas frame time | \u003c 3 ms |\n\n---\n\n## Browser compatibility\n\n| Feature | Chrome | Firefox | Safari |\n|---|---|---|---|\n| Canvas 2D | all | all | all |\n| `navigator.clipboard.writeText()` | 66+ | 63+ | 13.1+ |\n| `requestIdleCallback` | 47+ | 55+ | setTimeout fallback |\n| `document.fonts.ready` | 35+ | 41+ | 10+ |\n| Shadow Semantic Tree / aria-live | all | all | all |\n\nMinimum: Chrome 66, Firefox 63, Safari 13.1.\n\n---\n\n## Demos\n\n```sh\nnpm run build\nnpm run demo:serve      # serves demo/index.html on localhost:3000\n```\n\n| Demo | What it shows |\n|---|---|\n| `demo/index.html` | Unified demo: 100k benchmark + interactive text (selection, copy, search, links, a11y) |\n| `demo/basic-flex.ts` | 5-line flex row (Node) |\n| `demo/magazine.ts` | Multi-column text flow (Node) |\n| `demo/virtualization.ts` | 100,000 variable-height items (Node) |\n\n---\n\n## Benchmarks\n\n```sh\nnpm run bench\n```\n\n| Scenario | LayoutSans | vs DOM | vs Yoga WASM |\n|---|---:|---:|---:|\n| 100 flex boxes | 0.27 ms | 30× | 3× |\n| 10,000 flex boxes | 4.82 ms | 166× | 2× |\n| 100,000 var-height | 46 ms | ∞ | 2× |\n| buildIndex() at 100k | 11 ms | — | — |\n| queryPoint() p95 at 100k | \u003c 0.5 ms | — | — |\n| resolvePixelToCursor() p95 | \u003c 0.1 ms | — | — |\n\n---\n\n## Roadmap\n\n**v0.2 — current**\n- Canvas text selection + OS clipboard (desktop \u0026 mobile)\n- O(log n) spatial hit-testing via packed R-Tree\n- Interactive hyperlinks (mouse + Tab + keyboard)\n- Full-text search (Ctrl+F) with canvas highlighting\n- Virtualized shadow semantic tree (VoiceOver, NVDA, JAWS)\n- Mobile long-press with native teardrop selection handles\n- O(viewport) DOM node count regardless of total item count\n\n**v0.3**\n- Named grid template areas\n- CSS `aspect-ratio`\n- Enhanced ARIA role/label per record\n\n**v0.4**\n- RTL layout\n- Full CSS grid (template columns/rows, named lines, span)\n- Baseline alignment\n\n---\n\n## Support\n\n[![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/V7V01X2WY5)\n\n---\n\n## License\n\nMIT\n\n---\n\n## Acknowledgements\n\n- **[Pretext](https://github.com/chenglou/pretext)** by [@_chenglou](https://x.com/_chenglou) — the pure-math text measurement layer that makes LayoutSans possible.\n- **[Yoga](https://github.com/nicolo-ribaudo/yoga-layout)** by Meta — the production flexbox engine that inspired LayoutSans's API design.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbaselashraf81%2Flayout-sans","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbaselashraf81%2Flayout-sans","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbaselashraf81%2Flayout-sans/lists"}