From 11891dc7dc213e9acd2321b7db4919bee28f866b Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Tue, 31 Mar 2026 08:05:53 -0400 Subject: [PATCH 1/2] feat: add measurement host config entrypoint --- .gitignore | 1 + .../2026-03-31-measurement-host-config.md | 222 ++++++++++++++++++ ...26-03-31-measurement-host-config-design.md | 146 ++++++++++++ package.json | 5 + src/host.ts | 67 ++++++ src/layout.test.ts | 90 +++++++ src/measurement.ts | 127 ++++++++-- 7 files changed, 642 insertions(+), 16 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-31-measurement-host-config.md create mode 100644 docs/superpowers/specs/2026-03-31-measurement-host-config-design.md create mode 100644 src/host.ts diff --git a/.gitignore b/.gitignore index 7428ea11..5b19406e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # IntelliJ based IDEs .idea +.worktrees/ # Finder (MacOS) folder config .DS_Store diff --git a/docs/superpowers/plans/2026-03-31-measurement-host-config.md b/docs/superpowers/plans/2026-03-31-measurement-host-config.md new file mode 100644 index 00000000..0e8cc0d1 --- /dev/null +++ b/docs/superpowers/plans/2026-03-31-measurement-host-config.md @@ -0,0 +1,222 @@ +# Measurement Host Config Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `pretext` expose a host-config entrypoint for measurement so wrapper packages can bind non-browser measurement backends without changing the root browser API. + +**Architecture:** Add a new `src/host.ts` entrypoint that exposes `createPretext(config)`, and implement host binding by routing the existing engine through a call-scoped measurement override in `src/measurement.ts`. Publish the advanced factory from a new package subpath so wrapper packages can opt in without changing normal user imports. + +**Tech Stack:** TypeScript, Bun tests, package exports + +--- + +### Task 1: Add a regression test for the advanced host seam + +**Files:** +- Modify: `src/layout.test.ts` +- Test: `src/layout.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +test('createPretext binds prepare/layout to an injected measurement host', async () => { + const hostState = { + engineProfile: { + lineFitEpsilon: 0.005, + carryCJKAfterClosingQuote: false, + preferPrefixWidthsForBreakableRuns: false, + preferEarlySoftHyphenBreak: false, + }, + cleared: 0, + } + + const api = createPretext({ + measurement: { + clearMeasurementCaches() { + hostState.cleared++ + }, + getEngineProfile() { + return hostState.engineProfile + }, + getFontMeasurementState(font) { + return { cache: new Map(), fontSize: parseFontSize(font), emojiCorrection: 0 } + }, + getSegmentMetrics(seg) { + return { width: measureWidth(seg, FONT), containsCJK: isWideCharacter(seg[0] ?? '') } + }, + getCorrectedSegmentWidth(_seg, metrics) { + return metrics.width + }, + getSegmentGraphemeWidths() { + return null + }, + getSegmentGraphemePrefixWidths() { + return null + }, + textMayContainEmoji() { + return false + }, + }, + }) + + const prepared = api.prepare('Hello world', FONT) + expect(api.layout(prepared, 60, LINE_HEIGHT).lineCount).toBeGreaterThan(0) + + api.clearCache() + expect(hostState.cleared).toBe(1) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `bun test src/layout.test.ts` +Expected: FAIL because `createPretext` does not exist yet. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// Add a new host entrypoint that exports createPretext(config) +// and wire clearCache() through the injected measurement host. +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `bun test src/layout.test.ts` +Expected: PASS for the new host-config test and no regressions in the existing invariant tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/layout.test.ts src/host.ts src/measurement.ts package.json +git commit -m "feat: add measurement host-config entrypoint" +``` + +### Task 2: Add the host entrypoint and internal call-scoped measurement binding + +**Files:** +- Create: `src/host.ts` +- Modify: `src/measurement.ts` +- Modify: `package.json` + +- [ ] **Step 1: Add a measurement host type and call-scoped override** + +```ts +export function withMeasurementHost(measurementHost: MeasurementHost, fn: () => T): T { + const previousHost = measurementHostOverride + measurementHostOverride = measurementHost + try { + return fn() + } finally { + measurementHostOverride = previousHost + } +} +``` + +- [ ] **Step 2: Expose `createPretext(config)` from `src/host.ts`** + +```ts +export function createPretext(config: PretextHostConfig): PretextHostApi { + return { + profilePrepare: bind(profilePrepare), + prepare: bind(prepare), + prepareWithSegments: bind(prepareWithSegments), + layout: bind(layout), + walkLineRanges: bind(walkLineRanges), + layoutNextLine: bind(layoutNextLine), + layoutWithLines: bind(layoutWithLines), + clearCache: bind(clearCache), + setLocale, + } +} +``` + +- [ ] **Step 3: Keep the root browser package unchanged and export the advanced subpath** + +```json +{ + "exports": { + ".": { + "types": "./dist/layout.d.ts", + "import": "./dist/layout.js", + "default": "./dist/layout.js" + }, + "./host": { + "types": "./dist/host.d.ts", + "import": "./dist/host.js", + "default": "./dist/host.js" + } + } +} +``` + +- [ ] **Step 4: Run typecheck and package build** + +Run: `bun run check && bun run build:package` +Expected: exit 0, `dist/layout.*` and `dist/host.*` generated successfully. + +- [ ] **Step 5: Commit** + +```bash +git add package.json src/host.ts src/measurement.ts +git commit -m "feat: add measurement host-config entrypoint" +``` + +### Task 3: Verify browser-root behavior stays unchanged + +**Files:** +- Modify: `src/layout.test.ts` +- Test: `src/layout.test.ts` + +- [ ] **Step 1: Add one explicit browser-root smoke assertion** + +```ts +test('root browser entrypoint still uses the browser measurement host', () => { + const prepared = prepare('Hello world', FONT) + const result = layout(prepared, 60, LINE_HEIGHT) + expect(result.lineCount).toBeGreaterThan(0) +}) +``` + +- [ ] **Step 2: Run tests to verify the root wrapper stays green** + +Run: `bun test` +Expected: PASS with the existing 60 invariant tests plus the new host-config coverage. + +- [ ] **Step 3: Run package smoke test** + +Run: `bun run package-smoke-test` +Expected: exit 0, package entrypoints resolve correctly after the new export. + +- [ ] **Step 4: Commit** + +```bash +git add src/layout.test.ts +git commit -m "test: cover browser root and host-config entrypoints" +``` + +### Task 4: Record the deferred Lynx for Web follow-up + +**Files:** +- Modify: `docs/superpowers/specs/2026-03-31-measurement-host-config-design.md` + +- [ ] **Step 1: Keep the deferred web-platform note explicit** + +```md +## Deferred Follow-Up + +Implementing `lynx.getTextInfo` on Lynx for Web is a separate plan item. +This refactor only creates the seam that lets wrapper packages choose their +measurement backend. +``` + +- [ ] **Step 2: Final verification** + +Run: `bun test && bun run check && bun run build:package && bun run package-smoke-test` +Expected: all commands exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add docs/superpowers/specs/2026-03-31-measurement-host-config-design.md +git commit -m "docs: record deferred lynx web getTextInfo follow-up" +``` diff --git a/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md b/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md new file mode 100644 index 00000000..1202f47e --- /dev/null +++ b/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md @@ -0,0 +1,146 @@ +# Measurement Host Config Design + +## Goal + +Make `pretext` keep its current browser-first public API while allowing wrapper packages such as `lynx-pretext` to bind a different measurement implementation without forking the layout engine. + +## Problem + +Today `pretext` hard-binds browser canvas measurement in two places: + +- `src/layout.ts` imports the browser `measurement.ts` module directly. +- `src/line-break.ts` imports `getEngineProfile()` from the same module directly. + +That makes `lynx-pretext` copy large parts of `pretext` just to swap the measurement primitive from canvas to `lynx.getTextInfo()`. It also blocks an upstreamable host-config path because the current public package exports only the browser-bound entrypoint. + +## Goals + +- Keep the root `@chenglou/pretext` API unchanged for normal browser users. +- Add an advanced host-config entrypoint that wrapper packages can bind once. +- Avoid module-level mutable backend registration in the hot path. +- Keep the current layout semantics and performance characteristics for the browser build. +- Make the first host-config seam measurement-focused, but shaped so future host differences can grow under the same config object. + +## Non-Goals + +- Do not add runtime platform detection to the main package root. +- Do not implement `lynx.getTextInfo` on Lynx for Web in this change. +- Do not change the public `prepare()` / `layout()` signatures. + +## Design + +### 1. Add an advanced host entrypoint + +Add a new exported subpath, tentatively `@chenglou/pretext/host`, that exposes: + +- `createPretext(config: PretextHostConfig)` +- host-related types (`PretextHostConfig`, `MeasurementHost`, shared metric types) + +The root package entrypoint remains browser-bound and unchanged. + +`PretextHostConfig` starts as: + +```ts +type PretextHostConfig = { + measurement: MeasurementHost +} +``` + +This keeps room for future host-specific seams without forcing another API break. + +### 2. Make the measurement seam mirror the existing module surface + +Rather than inventing a new minimal interface and then translating both implementations into it, the host abstraction should mirror the current measurement module surface closely. That keeps the refactor smaller and lets both browser and Lynx implementations slot in naturally. + +The host shape is: + +```ts +type MeasurementHost = { + clearMeasurementCaches(): void + getSegmentMetrics(seg: string, cache: Map): SegmentMetrics + getEngineProfile(): EngineProfile + getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number + getSegmentGraphemeWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getSegmentGraphemePrefixWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getFontMeasurementState( + font: string, + needsEmojiCorrection: boolean, + ): { + cache: Map + fontSize: number + emojiCorrection: number + } + textMayContainEmoji(text: string): boolean +} +``` + +### 3. Initial implementation: call-scoped measurement binding + +The first implementation does not physically split `layout.ts` and `line-break.ts` into separate factories yet. Instead it introduces: + +- `src/host.ts` +- an internal call-scoped measurement host override inside `src/measurement.ts` + +`createPretext(config)` returns wrapper functions that execute the existing exported layout APIs inside `withMeasurementHost(config.measurement, ...)`. + +That means: + +- the root browser package keeps using the existing browser measurement implementation by default +- wrapper packages get a real explicit host-config entrypoint immediately +- the refactor stays small enough to land without moving two large core files at once + +This is intentionally a first seam, not the final internal architecture. If upstream later wants a deeper cleanup, the current external `createPretext(config)` contract can stay stable while the internals move from dynamic binding to fully split factories. + +### 4. Export strategy + +Update `package.json` exports with a new subpath for the advanced factory. The root export remains the same. + +That gives future wrappers two paths: + +- today: `lynx-pretext` can import the host entrypoint explicitly +- later: upstream can decide whether it wants a first-party shell like `@chenglou/pretext/lynx` + +### 5. Testing + +Keep the current browser-root tests passing unchanged, then add one focused host-config test path: + +- bind `createPretext()` to a deterministic fake measurement host +- verify `prepare()` / `layout()` still behave correctly through the advanced entrypoint +- verify cache clearing still resets both analysis caches and host measurement caches + +The purpose is not to duplicate the whole test suite through two entrypoints; it is to prove that the new host seam is real and not accidentally still coupled to browser measurement internals. + +## Trade-Offs + +### Why not runtime backend detection? + +Runtime detection would keep the short diff smaller, but it would hard-code host concerns into the root package and make future host differences harder to reason about. It also fails the requirement that wrappers should own host selection. + +### Why is the initial implementation still using internal dynamic scope? + +Because `line-break.ts` currently imports `getEngineProfile()` directly from `measurement.ts`, a full physical extraction would be a much larger move. The chosen implementation keeps the external API in the approved host-config shape while limiting the first diff to the measurement seam. + +The important distinction from a plain global setter is that backend choice is explicit and call-scoped through `createPretext(config)`, not process-global initialization state. + +## Migration Path + +1. Land the host-config factory and call-scoped measurement binding in `pretext`. +2. Keep the root browser API unchanged. +3. Update `lynx-pretext` to consume the new host entrypoint instead of maintaining a deep fork. + +## Deferred Follow-Up + +The separate idea of implementing `lynx.getTextInfo` on Lynx for Web should stay as a later plan item. The likely direction is: + +- width-only measurement can use browser canvas safely +- full `getTextInfo` parity for wrapping/content likely needs a second browser-specific layout strategy and should not block this host-config refactor diff --git a/package.json b/package.json index bda5a13a..b97bba68 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,11 @@ "import": "./dist/layout.js", "default": "./dist/layout.js" }, + "./host": { + "types": "./dist/host.d.ts", + "import": "./dist/host.js", + "default": "./dist/host.js" + }, "./demos/*": "./pages/demos/*", "./assets/*": "./pages/assets/*", "./package.json": "./package.json" diff --git a/src/host.ts b/src/host.ts new file mode 100644 index 00000000..a9cfccd7 --- /dev/null +++ b/src/host.ts @@ -0,0 +1,67 @@ +import { + clearCache, + layout, + layoutNextLine, + layoutWithLines, + prepare, + prepareWithSegments, + profilePrepare, + setLocale, + walkLineRanges, + type LayoutCursor, + type LayoutLine, + type LayoutLineRange, + type LayoutLinesResult, + type LayoutResult, + type PrepareOptions, + type PrepareProfile, + type PreparedText, + type PreparedTextWithSegments, +} from './layout.js' +import { withMeasurementHost, type MeasurementHost } from './measurement.js' + +export type PretextHostConfig = { + measurement: MeasurementHost +} + +export type PretextHostApi = { + profilePrepare(text: string, font: string, options?: PrepareOptions): PrepareProfile + prepare(text: string, font: string, options?: PrepareOptions): PreparedText + prepareWithSegments(text: string, font: string, options?: PrepareOptions): PreparedTextWithSegments + layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult + walkLineRanges( + prepared: PreparedTextWithSegments, + maxWidth: number, + onLine?: (line: LayoutLineRange) => void, + ): number + layoutNextLine( + prepared: PreparedTextWithSegments, + start: LayoutCursor, + maxWidth: number, + ): LayoutLine | null + layoutWithLines(prepared: PreparedTextWithSegments, maxWidth: number, lineHeight: number): LayoutLinesResult + clearCache(): void + setLocale(locale?: string): void +} + +export { type MeasurementHost } from './measurement.js' + +export function createPretext(config: PretextHostConfig): PretextHostApi { + const bind = ( + fn: (...args: Args) => Result, + ): ((...args: Args) => Result) => { + return (...args: Args) => withMeasurementHost(config.measurement, () => fn(...args)) + } + + return { + profilePrepare: bind(profilePrepare), + prepare: bind(prepare), + prepareWithSegments: bind(prepareWithSegments), + layout: bind(layout), + walkLineRanges: bind(walkLineRanges), + layoutNextLine: bind(layoutNextLine), + layoutWithLines: bind(layoutWithLines), + clearCache: bind(clearCache), + setLocale, + } +} diff --git a/src/layout.test.ts b/src/layout.test.ts index 3b5d01bb..7ccd611f 100644 --- a/src/layout.test.ts +++ b/src/layout.test.ts @@ -625,3 +625,93 @@ describe('layout invariants', () => { } }) }) + +describe('host config', () => { + test('createPretext binds prepare/layout to an injected measurement host', async () => { + const hostMod = await import('./host.ts') as { + createPretext: (config: { + measurement: { + clearMeasurementCaches(): void + getSegmentMetrics(seg: string, cache: Map): { width: number, containsCJK: boolean } + getEngineProfile(): { + lineFitEpsilon: number + carryCJKAfterClosingQuote: boolean + preferPrefixWidthsForBreakableRuns: boolean + preferEarlySoftHyphenBreak: boolean + } + getCorrectedSegmentWidth( + seg: string, + metrics: { width: number, containsCJK: boolean }, + emojiCorrection: number, + ): number + getSegmentGraphemeWidths(): null + getSegmentGraphemePrefixWidths(): null + getFontMeasurementState(font: string, needsEmojiCorrection: boolean): { + cache: Map + fontSize: number + emojiCorrection: number + } + textMayContainEmoji(text: string): boolean + } + }) => { + prepare(text: string, font: string): ReturnType + layout(prepared: ReturnType, maxWidth: number, lineHeight: number): ReturnType + clearCache(): void + } + } + + let cleared = 0 + const measuredSegments: string[] = [] + const cache = new Map() + const api = hostMod.createPretext({ + measurement: { + clearMeasurementCaches() { + cleared++ + cache.clear() + }, + getSegmentMetrics(seg: string) { + measuredSegments.push(seg) + return { + width: measureWidth(seg, FONT), + containsCJK: seg.length > 0 && isWideCharacter(seg[0]!), + } + }, + getEngineProfile() { + return { + lineFitEpsilon: 0.005, + carryCJKAfterClosingQuote: false, + preferPrefixWidthsForBreakableRuns: false, + preferEarlySoftHyphenBreak: false, + } + }, + getCorrectedSegmentWidth(_seg, metrics) { + return metrics.width + }, + getSegmentGraphemeWidths() { + return null + }, + getSegmentGraphemePrefixWidths() { + return null + }, + getFontMeasurementState(font: string, _needsEmojiCorrection: boolean) { + return { + cache, + fontSize: parseFontSize(font), + emojiCorrection: 0, + } + }, + textMayContainEmoji() { + return false + }, + }, + }) + + const prepared = api.prepare('Hello world', FONT) + expect(api.layout(prepared, 60, LINE_HEIGHT).lineCount).toBeGreaterThan(0) + expect(measuredSegments).toContain('Hello') + expect(measuredSegments).toContain('world') + + api.clearCache() + expect(cleared).toBe(1) + }) +}) diff --git a/src/measurement.ts b/src/measurement.ts index b2fb6d57..32717a5f 100644 --- a/src/measurement.ts +++ b/src/measurement.ts @@ -15,9 +15,37 @@ export type EngineProfile = { preferEarlySoftHyphenBreak: boolean } +export type FontMeasurementState = { + cache: Map + fontSize: number + emojiCorrection: number +} + +export type MeasurementHost = { + clearMeasurementCaches(): void + getSegmentMetrics(seg: string, cache: Map): SegmentMetrics + getEngineProfile(): EngineProfile + getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number + getSegmentGraphemeWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getSegmentGraphemePrefixWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, + ): number[] | null + getFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState + textMayContainEmoji(text: string): boolean +} + let measureContext: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D | null = null const segmentMetricCaches = new Map>() let cachedEngineProfile: EngineProfile | null = null +let measurementHostOverride: MeasurementHost | null = null const emojiPresentationRe = /\p{Emoji_Presentation}/u const maybeEmojiRe = /[\p{Emoji_Presentation}\p{Extended_Pictographic}\p{Regional_Indicator}\uFE0F\u20E3]/u @@ -49,7 +77,7 @@ export function getSegmentMetricCache(font: string): Map return cache } -export function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics { +function browserGetSegmentMetrics(seg: string, cache: Map): SegmentMetrics { let metrics = cache.get(seg) if (metrics === undefined) { const ctx = getMeasureContext() @@ -62,7 +90,7 @@ export function getSegmentMetrics(seg: string, cache: Map, @@ -182,15 +214,15 @@ export function getSegmentGraphemeWidths( const widths: number[] = [] const graphemeSegmenter = getSharedGraphemeSegmenter() for (const gs of graphemeSegmenter.segment(seg)) { - const graphemeMetrics = getSegmentMetrics(gs.segment, cache) - widths.push(getCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection)) + const graphemeMetrics = browserGetSegmentMetrics(gs.segment, cache) + widths.push(browserGetCorrectedSegmentWidth(gs.segment, graphemeMetrics, emojiCorrection)) } metrics.graphemeWidths = widths.length > 1 ? widths : null return metrics.graphemeWidths } -export function getSegmentGraphemePrefixWidths( +function browserGetSegmentGraphemePrefixWidths( seg: string, metrics: SegmentMetrics, cache: Map, @@ -203,19 +235,15 @@ export function getSegmentGraphemePrefixWidths( let prefix = '' for (const gs of graphemeSegmenter.segment(seg)) { prefix += gs.segment - const prefixMetrics = getSegmentMetrics(prefix, cache) - prefixWidths.push(getCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection)) + const prefixMetrics = browserGetSegmentMetrics(prefix, cache) + prefixWidths.push(browserGetCorrectedSegmentWidth(prefix, prefixMetrics, emojiCorrection)) } metrics.graphemePrefixWidths = prefixWidths.length > 1 ? prefixWidths : null return metrics.graphemePrefixWidths } -export function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): { - cache: Map - fontSize: number - emojiCorrection: number -} { +function browserGetFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState { const ctx = getMeasureContext() ctx.font = font const cache = getSegmentMetricCache(font) @@ -224,8 +252,75 @@ export function getFontMeasurementState(font: string, needsEmojiCorrection: bool return { cache, fontSize, emojiCorrection } } -export function clearMeasurementCaches(): void { +function browserClearMeasurementCaches(): void { segmentMetricCaches.clear() emojiCorrectionCache.clear() sharedGraphemeSegmenter = null } + +export const browserMeasurementHost: MeasurementHost = { + clearMeasurementCaches: browserClearMeasurementCaches, + getSegmentMetrics: browserGetSegmentMetrics, + getEngineProfile: browserGetEngineProfile, + getCorrectedSegmentWidth: browserGetCorrectedSegmentWidth, + getSegmentGraphemeWidths: browserGetSegmentGraphemeWidths, + getSegmentGraphemePrefixWidths: browserGetSegmentGraphemePrefixWidths, + getFontMeasurementState: browserGetFontMeasurementState, + textMayContainEmoji: browserTextMayContainEmoji, +} + +function getActiveMeasurementHost(): MeasurementHost { + return measurementHostOverride ?? browserMeasurementHost +} + +export function withMeasurementHost(measurementHost: MeasurementHost, fn: () => T): T { + const previousHost = measurementHostOverride + measurementHostOverride = measurementHost + try { + return fn() + } finally { + measurementHostOverride = previousHost + } +} + +export function getSegmentMetrics(seg: string, cache: Map): SegmentMetrics { + return getActiveMeasurementHost().getSegmentMetrics(seg, cache) +} + +export function getEngineProfile(): EngineProfile { + return getActiveMeasurementHost().getEngineProfile() +} + +export function textMayContainEmoji(text: string): boolean { + return getActiveMeasurementHost().textMayContainEmoji(text) +} + +export function getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number { + return getActiveMeasurementHost().getCorrectedSegmentWidth(seg, metrics, emojiCorrection) +} + +export function getSegmentGraphemeWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, +): number[] | null { + return getActiveMeasurementHost().getSegmentGraphemeWidths(seg, metrics, cache, emojiCorrection) +} + +export function getSegmentGraphemePrefixWidths( + seg: string, + metrics: SegmentMetrics, + cache: Map, + emojiCorrection: number, +): number[] | null { + return getActiveMeasurementHost().getSegmentGraphemePrefixWidths(seg, metrics, cache, emojiCorrection) +} + +export function getFontMeasurementState(font: string, needsEmojiCorrection: boolean): FontMeasurementState { + return getActiveMeasurementHost().getFontMeasurementState(font, needsEmojiCorrection) +} + +export function clearMeasurementCaches(): void { + getActiveMeasurementHost().clearMeasurementCaches() +} From 96ca3b3c38fdaa25c3f84811ce27f587b9a254c2 Mon Sep 17 00:00:00 2001 From: "xuan.huang" <5563315+Huxpro@users.noreply.github.com> Date: Wed, 1 Apr 2026 03:58:42 -0400 Subject: [PATCH 2/2] chore: drop local planning artifacts from PR --- .gitignore | 1 - .../2026-03-31-measurement-host-config.md | 222 ------------------ ...26-03-31-measurement-host-config-design.md | 146 ------------ 3 files changed, 369 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-31-measurement-host-config.md delete mode 100644 docs/superpowers/specs/2026-03-31-measurement-host-config-design.md diff --git a/.gitignore b/.gitignore index 5b19406e..7428ea11 100644 --- a/.gitignore +++ b/.gitignore @@ -30,7 +30,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # IntelliJ based IDEs .idea -.worktrees/ # Finder (MacOS) folder config .DS_Store diff --git a/docs/superpowers/plans/2026-03-31-measurement-host-config.md b/docs/superpowers/plans/2026-03-31-measurement-host-config.md deleted file mode 100644 index 0e8cc0d1..00000000 --- a/docs/superpowers/plans/2026-03-31-measurement-host-config.md +++ /dev/null @@ -1,222 +0,0 @@ -# Measurement Host Config Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `pretext` expose a host-config entrypoint for measurement so wrapper packages can bind non-browser measurement backends without changing the root browser API. - -**Architecture:** Add a new `src/host.ts` entrypoint that exposes `createPretext(config)`, and implement host binding by routing the existing engine through a call-scoped measurement override in `src/measurement.ts`. Publish the advanced factory from a new package subpath so wrapper packages can opt in without changing normal user imports. - -**Tech Stack:** TypeScript, Bun tests, package exports - ---- - -### Task 1: Add a regression test for the advanced host seam - -**Files:** -- Modify: `src/layout.test.ts` -- Test: `src/layout.test.ts` - -- [ ] **Step 1: Write the failing test** - -```ts -test('createPretext binds prepare/layout to an injected measurement host', async () => { - const hostState = { - engineProfile: { - lineFitEpsilon: 0.005, - carryCJKAfterClosingQuote: false, - preferPrefixWidthsForBreakableRuns: false, - preferEarlySoftHyphenBreak: false, - }, - cleared: 0, - } - - const api = createPretext({ - measurement: { - clearMeasurementCaches() { - hostState.cleared++ - }, - getEngineProfile() { - return hostState.engineProfile - }, - getFontMeasurementState(font) { - return { cache: new Map(), fontSize: parseFontSize(font), emojiCorrection: 0 } - }, - getSegmentMetrics(seg) { - return { width: measureWidth(seg, FONT), containsCJK: isWideCharacter(seg[0] ?? '') } - }, - getCorrectedSegmentWidth(_seg, metrics) { - return metrics.width - }, - getSegmentGraphemeWidths() { - return null - }, - getSegmentGraphemePrefixWidths() { - return null - }, - textMayContainEmoji() { - return false - }, - }, - }) - - const prepared = api.prepare('Hello world', FONT) - expect(api.layout(prepared, 60, LINE_HEIGHT).lineCount).toBeGreaterThan(0) - - api.clearCache() - expect(hostState.cleared).toBe(1) -}) -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `bun test src/layout.test.ts` -Expected: FAIL because `createPretext` does not exist yet. - -- [ ] **Step 3: Write minimal implementation** - -```ts -// Add a new host entrypoint that exports createPretext(config) -// and wire clearCache() through the injected measurement host. -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `bun test src/layout.test.ts` -Expected: PASS for the new host-config test and no regressions in the existing invariant tests. - -- [ ] **Step 5: Commit** - -```bash -git add src/layout.test.ts src/host.ts src/measurement.ts package.json -git commit -m "feat: add measurement host-config entrypoint" -``` - -### Task 2: Add the host entrypoint and internal call-scoped measurement binding - -**Files:** -- Create: `src/host.ts` -- Modify: `src/measurement.ts` -- Modify: `package.json` - -- [ ] **Step 1: Add a measurement host type and call-scoped override** - -```ts -export function withMeasurementHost(measurementHost: MeasurementHost, fn: () => T): T { - const previousHost = measurementHostOverride - measurementHostOverride = measurementHost - try { - return fn() - } finally { - measurementHostOverride = previousHost - } -} -``` - -- [ ] **Step 2: Expose `createPretext(config)` from `src/host.ts`** - -```ts -export function createPretext(config: PretextHostConfig): PretextHostApi { - return { - profilePrepare: bind(profilePrepare), - prepare: bind(prepare), - prepareWithSegments: bind(prepareWithSegments), - layout: bind(layout), - walkLineRanges: bind(walkLineRanges), - layoutNextLine: bind(layoutNextLine), - layoutWithLines: bind(layoutWithLines), - clearCache: bind(clearCache), - setLocale, - } -} -``` - -- [ ] **Step 3: Keep the root browser package unchanged and export the advanced subpath** - -```json -{ - "exports": { - ".": { - "types": "./dist/layout.d.ts", - "import": "./dist/layout.js", - "default": "./dist/layout.js" - }, - "./host": { - "types": "./dist/host.d.ts", - "import": "./dist/host.js", - "default": "./dist/host.js" - } - } -} -``` - -- [ ] **Step 4: Run typecheck and package build** - -Run: `bun run check && bun run build:package` -Expected: exit 0, `dist/layout.*` and `dist/host.*` generated successfully. - -- [ ] **Step 5: Commit** - -```bash -git add package.json src/host.ts src/measurement.ts -git commit -m "feat: add measurement host-config entrypoint" -``` - -### Task 3: Verify browser-root behavior stays unchanged - -**Files:** -- Modify: `src/layout.test.ts` -- Test: `src/layout.test.ts` - -- [ ] **Step 1: Add one explicit browser-root smoke assertion** - -```ts -test('root browser entrypoint still uses the browser measurement host', () => { - const prepared = prepare('Hello world', FONT) - const result = layout(prepared, 60, LINE_HEIGHT) - expect(result.lineCount).toBeGreaterThan(0) -}) -``` - -- [ ] **Step 2: Run tests to verify the root wrapper stays green** - -Run: `bun test` -Expected: PASS with the existing 60 invariant tests plus the new host-config coverage. - -- [ ] **Step 3: Run package smoke test** - -Run: `bun run package-smoke-test` -Expected: exit 0, package entrypoints resolve correctly after the new export. - -- [ ] **Step 4: Commit** - -```bash -git add src/layout.test.ts -git commit -m "test: cover browser root and host-config entrypoints" -``` - -### Task 4: Record the deferred Lynx for Web follow-up - -**Files:** -- Modify: `docs/superpowers/specs/2026-03-31-measurement-host-config-design.md` - -- [ ] **Step 1: Keep the deferred web-platform note explicit** - -```md -## Deferred Follow-Up - -Implementing `lynx.getTextInfo` on Lynx for Web is a separate plan item. -This refactor only creates the seam that lets wrapper packages choose their -measurement backend. -``` - -- [ ] **Step 2: Final verification** - -Run: `bun test && bun run check && bun run build:package && bun run package-smoke-test` -Expected: all commands exit 0. - -- [ ] **Step 3: Commit** - -```bash -git add docs/superpowers/specs/2026-03-31-measurement-host-config-design.md -git commit -m "docs: record deferred lynx web getTextInfo follow-up" -``` diff --git a/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md b/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md deleted file mode 100644 index 1202f47e..00000000 --- a/docs/superpowers/specs/2026-03-31-measurement-host-config-design.md +++ /dev/null @@ -1,146 +0,0 @@ -# Measurement Host Config Design - -## Goal - -Make `pretext` keep its current browser-first public API while allowing wrapper packages such as `lynx-pretext` to bind a different measurement implementation without forking the layout engine. - -## Problem - -Today `pretext` hard-binds browser canvas measurement in two places: - -- `src/layout.ts` imports the browser `measurement.ts` module directly. -- `src/line-break.ts` imports `getEngineProfile()` from the same module directly. - -That makes `lynx-pretext` copy large parts of `pretext` just to swap the measurement primitive from canvas to `lynx.getTextInfo()`. It also blocks an upstreamable host-config path because the current public package exports only the browser-bound entrypoint. - -## Goals - -- Keep the root `@chenglou/pretext` API unchanged for normal browser users. -- Add an advanced host-config entrypoint that wrapper packages can bind once. -- Avoid module-level mutable backend registration in the hot path. -- Keep the current layout semantics and performance characteristics for the browser build. -- Make the first host-config seam measurement-focused, but shaped so future host differences can grow under the same config object. - -## Non-Goals - -- Do not add runtime platform detection to the main package root. -- Do not implement `lynx.getTextInfo` on Lynx for Web in this change. -- Do not change the public `prepare()` / `layout()` signatures. - -## Design - -### 1. Add an advanced host entrypoint - -Add a new exported subpath, tentatively `@chenglou/pretext/host`, that exposes: - -- `createPretext(config: PretextHostConfig)` -- host-related types (`PretextHostConfig`, `MeasurementHost`, shared metric types) - -The root package entrypoint remains browser-bound and unchanged. - -`PretextHostConfig` starts as: - -```ts -type PretextHostConfig = { - measurement: MeasurementHost -} -``` - -This keeps room for future host-specific seams without forcing another API break. - -### 2. Make the measurement seam mirror the existing module surface - -Rather than inventing a new minimal interface and then translating both implementations into it, the host abstraction should mirror the current measurement module surface closely. That keeps the refactor smaller and lets both browser and Lynx implementations slot in naturally. - -The host shape is: - -```ts -type MeasurementHost = { - clearMeasurementCaches(): void - getSegmentMetrics(seg: string, cache: Map): SegmentMetrics - getEngineProfile(): EngineProfile - getCorrectedSegmentWidth(seg: string, metrics: SegmentMetrics, emojiCorrection: number): number - getSegmentGraphemeWidths( - seg: string, - metrics: SegmentMetrics, - cache: Map, - emojiCorrection: number, - ): number[] | null - getSegmentGraphemePrefixWidths( - seg: string, - metrics: SegmentMetrics, - cache: Map, - emojiCorrection: number, - ): number[] | null - getFontMeasurementState( - font: string, - needsEmojiCorrection: boolean, - ): { - cache: Map - fontSize: number - emojiCorrection: number - } - textMayContainEmoji(text: string): boolean -} -``` - -### 3. Initial implementation: call-scoped measurement binding - -The first implementation does not physically split `layout.ts` and `line-break.ts` into separate factories yet. Instead it introduces: - -- `src/host.ts` -- an internal call-scoped measurement host override inside `src/measurement.ts` - -`createPretext(config)` returns wrapper functions that execute the existing exported layout APIs inside `withMeasurementHost(config.measurement, ...)`. - -That means: - -- the root browser package keeps using the existing browser measurement implementation by default -- wrapper packages get a real explicit host-config entrypoint immediately -- the refactor stays small enough to land without moving two large core files at once - -This is intentionally a first seam, not the final internal architecture. If upstream later wants a deeper cleanup, the current external `createPretext(config)` contract can stay stable while the internals move from dynamic binding to fully split factories. - -### 4. Export strategy - -Update `package.json` exports with a new subpath for the advanced factory. The root export remains the same. - -That gives future wrappers two paths: - -- today: `lynx-pretext` can import the host entrypoint explicitly -- later: upstream can decide whether it wants a first-party shell like `@chenglou/pretext/lynx` - -### 5. Testing - -Keep the current browser-root tests passing unchanged, then add one focused host-config test path: - -- bind `createPretext()` to a deterministic fake measurement host -- verify `prepare()` / `layout()` still behave correctly through the advanced entrypoint -- verify cache clearing still resets both analysis caches and host measurement caches - -The purpose is not to duplicate the whole test suite through two entrypoints; it is to prove that the new host seam is real and not accidentally still coupled to browser measurement internals. - -## Trade-Offs - -### Why not runtime backend detection? - -Runtime detection would keep the short diff smaller, but it would hard-code host concerns into the root package and make future host differences harder to reason about. It also fails the requirement that wrappers should own host selection. - -### Why is the initial implementation still using internal dynamic scope? - -Because `line-break.ts` currently imports `getEngineProfile()` directly from `measurement.ts`, a full physical extraction would be a much larger move. The chosen implementation keeps the external API in the approved host-config shape while limiting the first diff to the measurement seam. - -The important distinction from a plain global setter is that backend choice is explicit and call-scoped through `createPretext(config)`, not process-global initialization state. - -## Migration Path - -1. Land the host-config factory and call-scoped measurement binding in `pretext`. -2. Keep the root browser API unchanged. -3. Update `lynx-pretext` to consume the new host entrypoint instead of maintaining a deep fork. - -## Deferred Follow-Up - -The separate idea of implementing `lynx.getTextInfo` on Lynx for Web should stay as a later plan item. The likely direction is: - -- width-only measurement can use browser canvas safely -- full `getTextInfo` parity for wrapping/content likely needs a second browser-specific layout strategy and should not block this host-config refactor