diff --git a/AGENTS.md b/AGENTS.md index fddd245..f3faebd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -283,6 +283,20 @@ Components SHOULD be developed and released in batches, not individually. The ty There is no fixed release schedule. Release when a batch feels complete. +### Tags and GitHub Releases + +- Release entries in `apps/docs/lib/releases.ts` and the docs site are the canonical source for jalco ui release metadata. +- Git tags and GitHub Releases SHOULD be created after the release PR is merged to `main`. +- Historical docs-site release entries MAY exist without matching git tags or GitHub Releases. +- The project starts clean with real git/GitHub releases at `2026.04.0`. +- Agents MUST NOT assume older release entries in `releases.ts` have matching tags unless verified. +- When creating a new real release, agents SHOULD: + 1. merge the PR to `main` + 2. pull latest `main` + 3. create tag `YYYY.MM.patch` + 4. push the tag + 5. create the GitHub Release for that tag + ### Release data All releases are defined in `lib/releases.ts`. Each release has: diff --git a/apps/docs/.source/browser.ts b/apps/docs/.source/browser.ts index 9bc3e6d..12992cd 100644 --- a/apps/docs/.source/browser.ts +++ b/apps/docs/.source/browser.ts @@ -7,6 +7,6 @@ const create = browser(); const browserCollections = { - docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }), + docs: create.doc("docs", {"index.mdx": () => import("../content/docs/index.mdx?collection=docs"), "components/activity-graph.mdx": () => import("../content/docs/components/activity-graph.mdx?collection=docs"), "components/ai-copy-button.mdx": () => import("../content/docs/components/ai-copy-button.mdx?collection=docs"), "components/api-ref-table.mdx": () => import("../content/docs/components/api-ref-table.mdx?collection=docs"), "components/balanced-text.mdx": () => import("../content/docs/components/balanced-text.mdx?collection=docs"), "components/chat-bubble.mdx": () => import("../content/docs/components/chat-bubble.mdx?collection=docs"), "components/code-block-command.mdx": () => import("../content/docs/components/code-block-command.mdx?collection=docs"), "components/code-block.mdx": () => import("../content/docs/components/code-block.mdx?collection=docs"), "components/code-line.mdx": () => import("../content/docs/components/code-line.mdx?collection=docs"), "components/color-palette.mdx": () => import("../content/docs/components/color-palette.mdx?collection=docs"), "components/commit-graph.mdx": () => import("../content/docs/components/commit-graph.mdx?collection=docs"), "components/contributor-grid.mdx": () => import("../content/docs/components/contributor-grid.mdx?collection=docs"), "components/cron-schedule.mdx": () => import("../content/docs/components/cron-schedule.mdx?collection=docs"), "components/diff-viewer.mdx": () => import("../content/docs/components/diff-viewer.mdx?collection=docs"), "components/env-table.mdx": () => import("../content/docs/components/env-table.mdx?collection=docs"), "components/file-tree.mdx": () => import("../content/docs/components/file-tree.mdx?collection=docs"), "components/github-button-group.mdx": () => import("../content/docs/components/github-button-group.mdx?collection=docs"), "components/github-stars-button.mdx": () => import("../content/docs/components/github-stars-button.mdx?collection=docs"), "components/json-viewer.mdx": () => import("../content/docs/components/json-viewer.mdx?collection=docs"), "components/kbd.mdx": () => import("../content/docs/components/kbd.mdx?collection=docs"), "components/license-badge.mdx": () => import("../content/docs/components/license-badge.mdx?collection=docs"), "components/log-viewer.mdx": () => import("../content/docs/components/log-viewer.mdx?collection=docs"), "components/logo-cloud.mdx": () => import("../content/docs/components/logo-cloud.mdx?collection=docs"), "components/masonry-grid.mdx": () => import("../content/docs/components/masonry-grid.mdx?collection=docs"), "components/npm-badge.mdx": () => import("../content/docs/components/npm-badge.mdx?collection=docs"), "components/pretext.mdx": () => import("../content/docs/components/pretext.mdx?collection=docs"), "components/producthunt-button.mdx": () => import("../content/docs/components/producthunt-button.mdx?collection=docs"), "components/repo-card.mdx": () => import("../content/docs/components/repo-card.mdx?collection=docs"), "components/request-viewer.mdx": () => import("../content/docs/components/request-viewer.mdx?collection=docs"), "components/status-indicator.mdx": () => import("../content/docs/components/status-indicator.mdx?collection=docs"), "components/stepper.mdx": () => import("../content/docs/components/stepper.mdx?collection=docs"), "components/testimonial.mdx": () => import("../content/docs/components/testimonial.mdx?collection=docs"), "components/tip-jar.mdx": () => import("../content/docs/components/tip-jar.mdx?collection=docs"), }), }; export default browserCollections; \ No newline at end of file diff --git a/apps/docs/.source/server.ts b/apps/docs/.source/server.ts index 7833df1..2fc7446 100644 --- a/apps/docs/.source/server.ts +++ b/apps/docs/.source/server.ts @@ -1,29 +1,33 @@ // @ts-nocheck -import * as __fd_glob_30 from "../content/docs/components/tip-jar.mdx?collection=docs" -import * as __fd_glob_29 from "../content/docs/components/testimonial.mdx?collection=docs" -import * as __fd_glob_28 from "../content/docs/components/stepper.mdx?collection=docs" -import * as __fd_glob_27 from "../content/docs/components/status-indicator.mdx?collection=docs" -import * as __fd_glob_26 from "../content/docs/components/request-viewer.mdx?collection=docs" -import * as __fd_glob_25 from "../content/docs/components/repo-card.mdx?collection=docs" -import * as __fd_glob_24 from "../content/docs/components/producthunt-button.mdx?collection=docs" -import * as __fd_glob_23 from "../content/docs/components/npm-badge.mdx?collection=docs" -import * as __fd_glob_22 from "../content/docs/components/logo-cloud.mdx?collection=docs" -import * as __fd_glob_21 from "../content/docs/components/log-viewer.mdx?collection=docs" -import * as __fd_glob_20 from "../content/docs/components/license-badge.mdx?collection=docs" -import * as __fd_glob_19 from "../content/docs/components/kbd.mdx?collection=docs" -import * as __fd_glob_18 from "../content/docs/components/json-viewer.mdx?collection=docs" -import * as __fd_glob_17 from "../content/docs/components/github-stars-button.mdx?collection=docs" -import * as __fd_glob_16 from "../content/docs/components/github-button-group.mdx?collection=docs" -import * as __fd_glob_15 from "../content/docs/components/file-tree.mdx?collection=docs" -import * as __fd_glob_14 from "../content/docs/components/env-table.mdx?collection=docs" -import * as __fd_glob_13 from "../content/docs/components/diff-viewer.mdx?collection=docs" -import * as __fd_glob_12 from "../content/docs/components/cron-schedule.mdx?collection=docs" -import * as __fd_glob_11 from "../content/docs/components/contributor-grid.mdx?collection=docs" -import * as __fd_glob_10 from "../content/docs/components/commit-graph.mdx?collection=docs" -import * as __fd_glob_9 from "../content/docs/components/color-palette.mdx?collection=docs" -import * as __fd_glob_8 from "../content/docs/components/code-line.mdx?collection=docs" -import * as __fd_glob_7 from "../content/docs/components/code-block.mdx?collection=docs" -import * as __fd_glob_6 from "../content/docs/components/code-block-command.mdx?collection=docs" +import * as __fd_glob_34 from "../content/docs/components/tip-jar.mdx?collection=docs" +import * as __fd_glob_33 from "../content/docs/components/testimonial.mdx?collection=docs" +import * as __fd_glob_32 from "../content/docs/components/stepper.mdx?collection=docs" +import * as __fd_glob_31 from "../content/docs/components/status-indicator.mdx?collection=docs" +import * as __fd_glob_30 from "../content/docs/components/request-viewer.mdx?collection=docs" +import * as __fd_glob_29 from "../content/docs/components/repo-card.mdx?collection=docs" +import * as __fd_glob_28 from "../content/docs/components/producthunt-button.mdx?collection=docs" +import * as __fd_glob_27 from "../content/docs/components/pretext.mdx?collection=docs" +import * as __fd_glob_26 from "../content/docs/components/npm-badge.mdx?collection=docs" +import * as __fd_glob_25 from "../content/docs/components/masonry-grid.mdx?collection=docs" +import * as __fd_glob_24 from "../content/docs/components/logo-cloud.mdx?collection=docs" +import * as __fd_glob_23 from "../content/docs/components/log-viewer.mdx?collection=docs" +import * as __fd_glob_22 from "../content/docs/components/license-badge.mdx?collection=docs" +import * as __fd_glob_21 from "../content/docs/components/kbd.mdx?collection=docs" +import * as __fd_glob_20 from "../content/docs/components/json-viewer.mdx?collection=docs" +import * as __fd_glob_19 from "../content/docs/components/github-stars-button.mdx?collection=docs" +import * as __fd_glob_18 from "../content/docs/components/github-button-group.mdx?collection=docs" +import * as __fd_glob_17 from "../content/docs/components/file-tree.mdx?collection=docs" +import * as __fd_glob_16 from "../content/docs/components/env-table.mdx?collection=docs" +import * as __fd_glob_15 from "../content/docs/components/diff-viewer.mdx?collection=docs" +import * as __fd_glob_14 from "../content/docs/components/cron-schedule.mdx?collection=docs" +import * as __fd_glob_13 from "../content/docs/components/contributor-grid.mdx?collection=docs" +import * as __fd_glob_12 from "../content/docs/components/commit-graph.mdx?collection=docs" +import * as __fd_glob_11 from "../content/docs/components/color-palette.mdx?collection=docs" +import * as __fd_glob_10 from "../content/docs/components/code-line.mdx?collection=docs" +import * as __fd_glob_9 from "../content/docs/components/code-block.mdx?collection=docs" +import * as __fd_glob_8 from "../content/docs/components/code-block-command.mdx?collection=docs" +import * as __fd_glob_7 from "../content/docs/components/chat-bubble.mdx?collection=docs" +import * as __fd_glob_6 from "../content/docs/components/balanced-text.mdx?collection=docs" import * as __fd_glob_5 from "../content/docs/components/api-ref-table.mdx?collection=docs" import * as __fd_glob_4 from "../content/docs/components/ai-copy-button.mdx?collection=docs" import * as __fd_glob_3 from "../content/docs/components/activity-graph.mdx?collection=docs" @@ -38,4 +42,4 @@ const create = server({"doc":{"passthroughs":["extractedReferences"]}}); -export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/code-block-command.mdx": __fd_glob_6, "components/code-block.mdx": __fd_glob_7, "components/code-line.mdx": __fd_glob_8, "components/color-palette.mdx": __fd_glob_9, "components/commit-graph.mdx": __fd_glob_10, "components/contributor-grid.mdx": __fd_glob_11, "components/cron-schedule.mdx": __fd_glob_12, "components/diff-viewer.mdx": __fd_glob_13, "components/env-table.mdx": __fd_glob_14, "components/file-tree.mdx": __fd_glob_15, "components/github-button-group.mdx": __fd_glob_16, "components/github-stars-button.mdx": __fd_glob_17, "components/json-viewer.mdx": __fd_glob_18, "components/kbd.mdx": __fd_glob_19, "components/license-badge.mdx": __fd_glob_20, "components/log-viewer.mdx": __fd_glob_21, "components/logo-cloud.mdx": __fd_glob_22, "components/npm-badge.mdx": __fd_glob_23, "components/producthunt-button.mdx": __fd_glob_24, "components/repo-card.mdx": __fd_glob_25, "components/request-viewer.mdx": __fd_glob_26, "components/status-indicator.mdx": __fd_glob_27, "components/stepper.mdx": __fd_glob_28, "components/testimonial.mdx": __fd_glob_29, "components/tip-jar.mdx": __fd_glob_30, }); \ No newline at end of file +export const docs = await create.docs("docs", "content/docs", {"meta.json": __fd_glob_0, "components/meta.json": __fd_glob_1, }, {"index.mdx": __fd_glob_2, "components/activity-graph.mdx": __fd_glob_3, "components/ai-copy-button.mdx": __fd_glob_4, "components/api-ref-table.mdx": __fd_glob_5, "components/balanced-text.mdx": __fd_glob_6, "components/chat-bubble.mdx": __fd_glob_7, "components/code-block-command.mdx": __fd_glob_8, "components/code-block.mdx": __fd_glob_9, "components/code-line.mdx": __fd_glob_10, "components/color-palette.mdx": __fd_glob_11, "components/commit-graph.mdx": __fd_glob_12, "components/contributor-grid.mdx": __fd_glob_13, "components/cron-schedule.mdx": __fd_glob_14, "components/diff-viewer.mdx": __fd_glob_15, "components/env-table.mdx": __fd_glob_16, "components/file-tree.mdx": __fd_glob_17, "components/github-button-group.mdx": __fd_glob_18, "components/github-stars-button.mdx": __fd_glob_19, "components/json-viewer.mdx": __fd_glob_20, "components/kbd.mdx": __fd_glob_21, "components/license-badge.mdx": __fd_glob_22, "components/log-viewer.mdx": __fd_glob_23, "components/logo-cloud.mdx": __fd_glob_24, "components/masonry-grid.mdx": __fd_glob_25, "components/npm-badge.mdx": __fd_glob_26, "components/pretext.mdx": __fd_glob_27, "components/producthunt-button.mdx": __fd_glob_28, "components/repo-card.mdx": __fd_glob_29, "components/request-viewer.mdx": __fd_glob_30, "components/status-indicator.mdx": __fd_glob_31, "components/stepper.mdx": __fd_glob_32, "components/testimonial.mdx": __fd_glob_33, "components/tip-jar.mdx": __fd_glob_34, }); \ No newline at end of file diff --git a/apps/docs/components/docs/dependency-badges.tsx b/apps/docs/components/docs/dependency-badges.tsx index 606c7ec..7158ec7 100644 --- a/apps/docs/components/docs/dependency-badges.tsx +++ b/apps/docs/components/docs/dependency-badges.tsx @@ -9,6 +9,12 @@ interface DependencyBadgesProps { className?: string } +/** + * Internal registry:lib items that should not appear as badges. + * These are installed automatically as transitive dependencies. + */ +const HIDDEN_REGISTRY_DEPS = new Set(["pretext"]) + /** * Maps npm dependency names to their bundled icon key. * Extend as new dependencies are added to registry items. @@ -137,7 +143,7 @@ export function DependencyBadges({ return (
{registryDependencies - .filter((dep) => !dep.startsWith("http")) + .filter((dep) => !dep.startsWith("http") && !HIDDEN_REGISTRY_DEPS.has(dep)) .map((dep) => { const parsed = parseRegistryDep(dep) return ( diff --git a/apps/docs/components/docs/previews/balanced-text.tsx b/apps/docs/components/docs/previews/balanced-text.tsx new file mode 100644 index 0000000..2bc18d0 --- /dev/null +++ b/apps/docs/components/docs/previews/balanced-text.tsx @@ -0,0 +1,28 @@ +"use client" + +import * as React from "react" +import { BalancedText } from "@/registry/balanced-text/balanced-text" + +export default function BalancedTextPreview() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return null + + return ( +
+ + +
+ ) +} diff --git a/apps/docs/components/docs/previews/chat-bubble.tsx b/apps/docs/components/docs/previews/chat-bubble.tsx new file mode 100644 index 0000000..7fc3d29 --- /dev/null +++ b/apps/docs/components/docs/previews/chat-bubble.tsx @@ -0,0 +1,23 @@ +"use client" + +import * as React from "react" +import { ChatThread, type ChatMessage } from "@/registry/chat-bubble/chat-bubble" + +const messages: ChatMessage[] = [ + { text: "Hey, did you see the new Pretext library?", sent: false }, + { text: "Yeah! Pure arithmetic text measurement.", sent: true }, + { text: "That shrinkwrap is wild β€” pixel-tight bubbles.", sent: false }, + { text: "CJK and emoji too πŸŽ‰", sent: true }, +] + +export default function ChatBubblePreview() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return null + + return ( +
+ +
+ ) +} diff --git a/apps/docs/components/docs/previews/masonry-grid.tsx b/apps/docs/components/docs/previews/masonry-grid.tsx new file mode 100644 index 0000000..8323ac2 --- /dev/null +++ b/apps/docs/components/docs/previews/masonry-grid.tsx @@ -0,0 +1,21 @@ +"use client" + +import * as React from "react" +import { MasonryGrid } from "@/registry/masonry-grid/masonry-grid" + +const items = [ + { title: "Quick thought", text: "Ship beats perfection." }, + { text: "The bug is never where you think it is." }, + { title: "Design", text: "A design system makes things feel intentional." }, + { text: "Good abstractions are discovered, not invented." }, + { title: "Typography", text: "If your UI looks wrong, check spacing first." }, + { text: "Documentation is the feature nobody asks for." }, +] + +export default function MasonryGridPreview() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return null + + return +} diff --git a/apps/docs/components/docs/previews/pretext.tsx b/apps/docs/components/docs/previews/pretext.tsx new file mode 100644 index 0000000..7b3dba4 --- /dev/null +++ b/apps/docs/components/docs/previews/pretext.tsx @@ -0,0 +1,44 @@ +"use client" + +import * as React from "react" +import { + usePretextWithSegments, + usePretextLayout, + useShrinkwrap, +} from "@/registry/pretext/use-pretext" + +const TEXT = "Pure arithmetic text measurement for layouts at any width." +const FONT = "14px ui-sans-serif, system-ui, sans-serif" + +export default function PretextPreview() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + + const prepared = usePretextWithSegments(TEXT, FONT) + const result = usePretextLayout(prepared, 200, 20) + const shrink = useShrinkwrap(prepared, 200) + + if (!mounted) return null + + return ( +
+
+
+

{result.lineCount}

+

Lines

+
+
+

{result.height}

+

Height

+
+
+

{shrink}

+

Shrinkwrap

+
+
+

+ {TEXT} +

+
+ ) +} diff --git a/apps/docs/content/docs/components/balanced-text.mdx b/apps/docs/content/docs/components/balanced-text.mdx new file mode 100644 index 0000000..cef16fb --- /dev/null +++ b/apps/docs/content/docs/components/balanced-text.mdx @@ -0,0 +1,42 @@ +--- +title: "Pretext Balanced Text" +description: "Text wrapper that uses Pretext to balance line widths so all lines are roughly equal length. Deterministic and cross-browser consistent." +--- + + + +## Installation + + + +## Usage + + + +`} /> + +Client component. The `font` prop must match the CSS font of the rendered text. Pretext uses Canvas-based measurement β€” no DOM reflow. + +## Examples + +### Heading vs body + + + +## API Reference + + + +## Notes + +- **Pretext-powered.** Uses [@chenglou/pretext](https://github.com/chenglou/pretext) for pure-arithmetic text measurement. +- **Better than CSS `text-wrap: balance`.** CSS balance only works up to ~6 lines, is inconsistent cross-browser, and can't be controlled. This works on any length and is deterministic. +- **Font matching.** The `font` prop must match the CSS font shorthand of the rendered text. Include weight and size (e.g. `'700 24px Inter'`). diff --git a/apps/docs/content/docs/components/chat-bubble.mdx b/apps/docs/content/docs/components/chat-bubble.mdx new file mode 100644 index 0000000..7d20ccc --- /dev/null +++ b/apps/docs/content/docs/components/chat-bubble.mdx @@ -0,0 +1,60 @@ +--- +title: "Pretext Chat Bubble" +description: "Message bubble with Pretext shrinkwrap that finds the tightest width for the same line count, eliminating dead space CSS fit-content leaves behind." +--- + + + +## Installation + + + +## Usage + + + +`} /> + +Client component. Each bubble uses Pretext to binary-search the tightest width that produces the same line count as `fit-content`, then sets that as the bubble width. Zero DOM reflow. + +## Examples + +### Shrinkwrap comparison + +Side-by-side showing CSS `fit-content` (dead space on short last lines) vs Pretext shrinkwrap (pixel-tight). + + + +### Single bubble + + + +## API Reference + + + + + +## Notes + +- **Pretext-powered.** Uses [@chenglou/pretext](https://github.com/chenglou/pretext) for pure-arithmetic text measurement β€” no DOM reflow. +- **Font matching.** The `font` prop must match the CSS font of the rendered bubble text. Defaults to `15px ui-sans-serif, system-ui, sans-serif`. +- **CJK and emoji.** Pretext handles CJK, Arabic, mixed bidi, grapheme clusters, and emoji correctly. diff --git a/apps/docs/content/docs/components/masonry-grid.mdx b/apps/docs/content/docs/components/masonry-grid.mdx new file mode 100644 index 0000000..374c325 --- /dev/null +++ b/apps/docs/content/docs/components/masonry-grid.mdx @@ -0,0 +1,53 @@ +--- +title: "Pretext Masonry Grid" +description: "Text-aware masonry layout where card heights are predicted by Pretext without DOM measurement. Zero layout shift." +--- + + + +## Installation + + + +## Usage + + + +`} /> + +Client component. Each card's text height is computed by Pretext using pure arithmetic. Cards are absolutely positioned into columns β€” no DOM measurement, no layout shift. + +## Examples + +### Three columns + + + +## API Reference + + + + + +## Notes + +- **Pretext-powered.** Uses [@chenglou/pretext](https://github.com/chenglou/pretext) for pure-arithmetic height prediction. +- **Zero layout shift.** Cards are positioned using computed heights, not DOM measurements. No reflow, no CLS. +- **Responsive.** Uses `ResizeObserver` to recalculate positions when the container width changes. +- **Font matching.** The `font` and `lineHeight` props must match the CSS styling of the card body text. diff --git a/apps/docs/content/docs/components/meta.json b/apps/docs/content/docs/components/meta.json index 1dacdbc..b69c808 100644 --- a/apps/docs/content/docs/components/meta.json +++ b/apps/docs/content/docs/components/meta.json @@ -35,6 +35,11 @@ "---Infrastructure---", "status-indicator", "---Payments---", - "tip-jar" + "tip-jar", + "---Pretext---", + "pretext", + "balanced-text", + "chat-bubble", + "masonry-grid" ] } diff --git a/apps/docs/content/docs/components/pretext.mdx b/apps/docs/content/docs/components/pretext.mdx new file mode 100644 index 0000000..21bf72d --- /dev/null +++ b/apps/docs/content/docs/components/pretext.mdx @@ -0,0 +1,78 @@ +--- +title: "Pretext Hooks" +description: "React hooks for DOM-free text measurement β€” prepare/layout lifecycle, shrinkwrap search, and balanced-width computation powered by @chenglou/pretext." +--- + + + +## Installation + + + +## Usage + + + + + +Client-only. Pretext uses Canvas for one-time text measurement, then all subsequent layout calls are pure arithmetic (~0.0002ms per call). The hooks manage the prepare/layout lifecycle so you don't need to call the Pretext API directly. + +## Hooks + +### `usePretext(text, font, options?)` + +Prepares text for measurement. Returns an opaque handle β€” pass it to `usePretextLayout`. Re-prepares only when `text` or `font` changes. + +### `usePretextWithSegments(text, font, options?)` + +Same as `usePretext` but returns a richer handle with segment data. Required for `usePretextLines`, `useShrinkwrap`, and `useBalancedWidth`. + +### `usePretextLayout(prepared, maxWidth, lineHeight)` + +Returns `{ lineCount, height }` for the given width. Pure arithmetic β€” runs on every resize at ~0.0002ms. + +### `usePretextLines(prepared, maxWidth, lineHeight)` + +Returns full line data including text content, width, and cursors per line. Use when you need to render lines manually or position highlights. + +### `useShrinkwrap(prepared, maxWidth)` + +Binary-searches for the tightest width that produces the same line count as `maxWidth`. Returns the shrinkwrapped width in pixels. This is what `ChatBubble` uses internally. + +### `useBalancedWidth(prepared, maxWidth)` + +Finds the narrowest width that keeps the same line count β€” making all lines roughly equal length. This is what `BalancedText` uses internally. + +## API Reference + + + + + + + +## Notes + +- **Powered by Pretext.** These hooks wrap [@chenglou/pretext](https://github.com/chenglou/pretext) by Cheng Lou β€” a 41k-star library for pure-JS text measurement. +- **Client-only.** Pretext requires Canvas (browser or OffscreenCanvas). These hooks cannot run during SSR. +- **Font matching.** The `font` string must match the CSS font shorthand of the rendered text. Include weight and size (e.g. `'700 24px Inter'`). Using `system-ui` is unsafe on macOS β€” use named fonts. +- **Caching.** `usePretext` and `usePretextWithSegments` memoize the prepared handle. The layout hooks re-run on every width change but are pure arithmetic (~0.0002ms). diff --git a/apps/docs/lib/docs.ts b/apps/docs/lib/docs.ts index 09f446e..1ac1722 100644 --- a/apps/docs/lib/docs.ts +++ b/apps/docs/lib/docs.ts @@ -114,4 +114,13 @@ export const docsNav: NavGroup[] = [ { title: "Crypto + Tip Jar", href: "/docs/components/tip-jar", dateAdded: "2026-03-11" }, ], }, + { + title: "Pretext", + items: [ + { title: "Pretext Hooks", href: "/docs/components/pretext", dateAdded: "2026-04-07" }, + { title: "Pretext Balanced Text", href: "/docs/components/balanced-text", dateAdded: "2026-04-07" }, + { title: "Pretext Chat Bubble", href: "/docs/components/chat-bubble", dateAdded: "2026-04-07" }, + { title: "Pretext Masonry Grid", href: "/docs/components/masonry-grid", dateAdded: "2026-04-07" }, + ], + }, ] diff --git a/apps/docs/lib/releases.ts b/apps/docs/lib/releases.ts index 8e74fcb..3ff6827 100644 --- a/apps/docs/lib/releases.ts +++ b/apps/docs/lib/releases.ts @@ -32,6 +32,50 @@ export interface Release { } export const releases: Release[] = [ + { + version: "2026.04.0", + date: "2026-04-07", + title: "Pretext", + summary: + "This one was me falling down a rabbit hole with Cheng Lou's Pretext library and deciding it was too interesting not to build around. I genuinely think Pretext is kind of revolutionary β€” it makes a bunch of text-layout problems feel solvable in a way the DOM never really has. So now jalco-ui has a small text-layout mini-batch: hooks for DOM-free text measurement, shrinkwrapped chat bubbles, balanced text, and a masonry grid that predicts heights without poking the DOM. Extremely niche in the best way.", + components: [ + { + name: "pretext", + title: "Pretext Hooks", + description: + "React hooks for DOM-free text measurement β€” prepare/layout lifecycle, shrinkwrap search, and balanced-width computation powered by @chenglou/pretext.", + category: "Pretext", + }, + { + name: "chat-bubble", + title: "Pretext Chat Bubble", + description: + "Message bubble with Pretext shrinkwrap that finds the tightest width for the same line count, eliminating dead space CSS fit-content leaves behind.", + category: "Pretext", + }, + { + name: "balanced-text", + title: "Pretext Balanced Text", + description: + "Text wrapper that uses Pretext to balance line widths so all lines are roughly equal length. Deterministic and cross-browser consistent.", + category: "Pretext", + }, + { + name: "masonry-grid", + title: "Pretext Masonry Grid", + description: + "Text-aware masonry layout where card heights are predicted by Pretext without DOM measurement. Zero layout shift.", + category: "Pretext", + }, + ], + improvements: [ + { + title: "Cleaner dependency badges", + description: + "Internal registry lib dependencies like the shared pretext utility are now hidden from docs-page dependency badges, so only the dependencies users actually care about show up.", + }, + ], + }, { version: "2026.03.1", date: "2026-03-29", diff --git a/apps/docs/package.json b/apps/docs/package.json index cc05e9b..2d83ff0 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -14,6 +14,7 @@ "prepare": "husky" }, "dependencies": { + "@chenglou/pretext": "^0.0.4", "@orama/orama": "^3.1.18", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.3", diff --git a/apps/docs/public/previews/balanced-text-dark.png b/apps/docs/public/previews/balanced-text-dark.png new file mode 100644 index 0000000..035a901 Binary files /dev/null and b/apps/docs/public/previews/balanced-text-dark.png differ diff --git a/apps/docs/public/previews/balanced-text-light.png b/apps/docs/public/previews/balanced-text-light.png new file mode 100644 index 0000000..414e76f Binary files /dev/null and b/apps/docs/public/previews/balanced-text-light.png differ diff --git a/apps/docs/public/previews/chat-bubble-dark.png b/apps/docs/public/previews/chat-bubble-dark.png new file mode 100644 index 0000000..70b2eef Binary files /dev/null and b/apps/docs/public/previews/chat-bubble-dark.png differ diff --git a/apps/docs/public/previews/chat-bubble-light.png b/apps/docs/public/previews/chat-bubble-light.png new file mode 100644 index 0000000..ce3a213 Binary files /dev/null and b/apps/docs/public/previews/chat-bubble-light.png differ diff --git a/apps/docs/public/previews/masonry-grid-dark.png b/apps/docs/public/previews/masonry-grid-dark.png new file mode 100644 index 0000000..c8f5e10 Binary files /dev/null and b/apps/docs/public/previews/masonry-grid-dark.png differ diff --git a/apps/docs/public/previews/masonry-grid-light.png b/apps/docs/public/previews/masonry-grid-light.png new file mode 100644 index 0000000..e93914e Binary files /dev/null and b/apps/docs/public/previews/masonry-grid-light.png differ diff --git a/apps/docs/public/previews/pretext-dark.png b/apps/docs/public/previews/pretext-dark.png new file mode 100644 index 0000000..f9da3af Binary files /dev/null and b/apps/docs/public/previews/pretext-dark.png differ diff --git a/apps/docs/public/previews/pretext-light.png b/apps/docs/public/previews/pretext-light.png new file mode 100644 index 0000000..2c6fa3e Binary files /dev/null and b/apps/docs/public/previews/pretext-light.png differ diff --git a/apps/docs/registry.json b/apps/docs/registry.json index f667b91..82aca1e 100644 --- a/apps/docs/registry.json +++ b/apps/docs/registry.json @@ -3,6 +3,26 @@ "name": "jalco-ui", "homepage": "https://ui.justinlevine.me", "items": [ + { + "name": "pretext", + "type": "registry:lib", + "title": "Pretext Hooks", + "description": "React hooks for DOM-free text measurement using @chenglou/pretext. Provides prepare/layout lifecycle, shrinkwrap search, and balanced-width computation.", + "dependencies": [ + "@chenglou/pretext" + ], + "registryDependencies": [], + "categories": [ + "layout", + "typography" + ], + "files": [ + { + "path": "registry/pretext/use-pretext.ts", + "type": "registry:lib" + } + ] + }, { "name": "code-line", "type": "registry:component", @@ -558,7 +578,7 @@ "name": "repo-card", "type": "registry:component", "title": "Repo Card", - "description": "GitHub repository preview card with description, language dot, star and fork counts, license, and topic tags. Async server component β€” fetches data at build time with ISR.", + "description": "GitHub repository preview card with description, language dot, star and fork counts, license, and topic tags. Async server component \u2014 fetches data at build time with ISR.", "dependencies": [ "class-variance-authority" ], @@ -595,6 +615,70 @@ "type": "registry:component" } ] + }, + { + "name": "chat-bubble", + "type": "registry:component", + "title": "Pretext Chat Bubble", + "description": "Message bubble with Pretext shrinkwrap that finds the tightest width for the same line count, eliminating dead space CSS fit-content leaves behind.", + "dependencies": [ + "@chenglou/pretext" + ], + "registryDependencies": [ + "pretext" + ], + "categories": [ + "communication", + "pretext" + ], + "files": [ + { + "path": "registry/chat-bubble/chat-bubble.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "balanced-text", + "type": "registry:component", + "title": "Pretext Balanced Text", + "description": "Text wrapper that uses Pretext to balance line widths so all lines are roughly equal length. Deterministic and cross-browser consistent.", + "dependencies": [ + "@chenglou/pretext" + ], + "registryDependencies": [ + "pretext" + ], + "categories": [ + "typography", + "pretext" + ], + "files": [ + { + "path": "registry/balanced-text/balanced-text.tsx", + "type": "registry:component" + } + ] + }, + { + "name": "masonry-grid", + "type": "registry:component", + "title": "Pretext Masonry Grid", + "description": "Text-aware masonry layout where card heights are predicted by Pretext without DOM measurement. Zero layout shift.", + "dependencies": [ + "@chenglou/pretext" + ], + "registryDependencies": [], + "categories": [ + "layout", + "pretext" + ], + "files": [ + { + "path": "registry/masonry-grid/masonry-grid.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/apps/docs/registry/balanced-text/balanced-text.tsx b/apps/docs/registry/balanced-text/balanced-text.tsx new file mode 100644 index 0000000..92de885 --- /dev/null +++ b/apps/docs/registry/balanced-text/balanced-text.tsx @@ -0,0 +1,65 @@ +/** + * jalco-ui + * BalancedText + * by Justin Levine + * ui.justinlevine.me + * + * Text wrapper that uses Pretext to balance line widths so all lines + * are roughly equal length. CSS `text-wrap: balance` only works up + * to ~6 lines and is inconsistent cross-browser. This is deterministic + * and works on any length. + * + * Dependencies: @chenglou/pretext (via pretext registry item) + * + * Powered by Pretext by Cheng Lou β€” github.com/chenglou/pretext + */ + +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + usePretextWithSegments, + useBalancedWidth, +} from "@/registry/pretext/use-pretext" + +interface BalancedTextProps extends React.ComponentProps<"div"> { + /** The text string to balance. */ + text: string + /** CSS font shorthand for Pretext measurement. Must match the rendered font. + * @default '16px ui-sans-serif, system-ui, sans-serif' */ + font?: string + /** Maximum width in pixels. The balanced width will never exceed this. @default 600 */ + maxWidth?: number + /** HTML element to render as. @default "p" */ + as?: "p" | "h1" | "h2" | "h3" | "h4" | "span" | "div" +} + +const DEFAULT_FONT = "16px ui-sans-serif, system-ui, sans-serif" +const DEFAULT_MAX_WIDTH = 600 + +function BalancedText({ + text, + font = DEFAULT_FONT, + maxWidth = DEFAULT_MAX_WIDTH, + as: Tag = "p", + className, + style, + ...props +}: BalancedTextProps) { + const prepared = usePretextWithSegments(text, font) + const balancedWidth = useBalancedWidth(prepared, maxWidth) + + return ( + + {text} + + ) +} + +export { BalancedText, type BalancedTextProps } diff --git a/apps/docs/registry/balanced-text/examples/balanced-text-demo.tsx b/apps/docs/registry/balanced-text/examples/balanced-text-demo.tsx new file mode 100644 index 0000000..82c1643 --- /dev/null +++ b/apps/docs/registry/balanced-text/examples/balanced-text-demo.tsx @@ -0,0 +1,62 @@ +"use client" + +import * as React from "react" +import { BalancedText } from "@/registry/balanced-text/balanced-text" + +const heading = "Build faster with components that measure text without the DOM" +const paragraph = + "Pretext uses pure arithmetic to compute text height and line breaks. No layout reflow, no hidden elements, no guesswork β€” just accurate measurements at any width, for any language." + +export default function BalancedTextDemo() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return
+ + return ( +
+
+
+

+ Unbalanced (default) +

+

+ {heading} +

+
+
+

+ Pretext balanced +

+ +
+
+
+
+

+ Unbalanced body +

+

+ {paragraph} +

+
+
+

+ Pretext balanced body +

+ +
+
+
+ ) +} diff --git a/apps/docs/registry/chat-bubble/chat-bubble.tsx b/apps/docs/registry/chat-bubble/chat-bubble.tsx new file mode 100644 index 0000000..49fdee6 --- /dev/null +++ b/apps/docs/registry/chat-bubble/chat-bubble.tsx @@ -0,0 +1,158 @@ +/** + * jalco-ui + * ChatBubble, ChatThread + * by Justin Levine + * ui.justinlevine.me + * + * Message bubble with optional Pretext shrinkwrap β€” finds the tightest + * width that keeps the same line count, eliminating dead space CSS + * `fit-content` leaves behind. Zero DOM reflow in the resize path. + * + * Exports: + * - ChatBubble β€” single message with sent/received variants + * - ChatThread β€” vertical message list with optional timestamp groups + * + * Dependencies: @chenglou/pretext (via pretext registry item) + * + * Powered by Pretext by Cheng Lou β€” github.com/chenglou/pretext + */ + +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import { + usePretextWithSegments, + useShrinkwrap, +} from "@/registry/pretext/use-pretext" + +interface ChatMessage { + /** Message text content. */ + text: string + /** Whether the message was sent by the current user. */ + sent?: boolean + /** Timestamp string (e.g. "2:34 PM"). */ + timestamp?: string +} + +interface ChatBubbleProps extends Omit, "children"> { + /** Message text. */ + text: string + /** Sent by current user. @default false */ + sent?: boolean + /** Timestamp label. */ + timestamp?: string + /** Enable Pretext shrinkwrap to eliminate dead space. @default true */ + shrinkwrap?: boolean + /** Maximum bubble width in pixels. @default 280 */ + maxWidth?: number + /** CSS font shorthand for Pretext measurement. Must match the rendered font. + * @default '15px/20px ui-sans-serif, system-ui, sans-serif' */ + font?: string +} + +const BUBBLE_PADDING_X = 24 +const DEFAULT_MAX_WIDTH = 280 +const DEFAULT_FONT = "15px ui-sans-serif, system-ui, sans-serif" + +function ChatBubble({ + text, + sent = false, + timestamp, + shrinkwrap = true, + maxWidth = DEFAULT_MAX_WIDTH, + font = DEFAULT_FONT, + className, + ...props +}: ChatBubbleProps) { + const textMaxWidth = Math.max(1, maxWidth - BUBBLE_PADDING_X) + + const prepared = usePretextWithSegments(text, font) + const shrinkwrappedWidth = useShrinkwrap(prepared, textMaxWidth) + + const bubbleWidth = shrinkwrap && shrinkwrappedWidth > 0 + ? Math.min(maxWidth, shrinkwrappedWidth + BUBBLE_PADDING_X) + : undefined + + return ( +
+
+ {text} +
+ {timestamp && ( + + {timestamp} + + )} +
+ ) +} + +interface ChatThreadProps extends Omit, "children"> { + /** Array of messages to display. */ + messages: ChatMessage[] + /** Enable Pretext shrinkwrap on all bubbles. @default true */ + shrinkwrap?: boolean + /** Maximum bubble width in pixels. @default 280 */ + maxWidth?: number + /** CSS font shorthand for Pretext measurement. @default '15px ui-sans-serif, system-ui, sans-serif' */ + font?: string +} + +function ChatThread({ + messages, + shrinkwrap = true, + maxWidth = DEFAULT_MAX_WIDTH, + font = DEFAULT_FONT, + className, + ...props +}: ChatThreadProps) { + return ( +
+ {messages.map((msg, i) => ( + + ))} +
+ ) +} + +export { + ChatBubble, + ChatThread, + type ChatBubbleProps, + type ChatThreadProps, + type ChatMessage, +} diff --git a/apps/docs/registry/chat-bubble/examples/chat-bubble-comparison.tsx b/apps/docs/registry/chat-bubble/examples/chat-bubble-comparison.tsx new file mode 100644 index 0000000..2504f0a --- /dev/null +++ b/apps/docs/registry/chat-bubble/examples/chat-bubble-comparison.tsx @@ -0,0 +1,38 @@ +"use client" + +import * as React from "react" +import { ChatThread, type ChatMessage } from "@/registry/chat-bubble/chat-bubble" + +const messages: ChatMessage[] = [ + { text: "The best part: zero layout reflow. You could shrinkwrap 10,000 bubbles and the browser wouldn't blink.", sent: true }, + { text: "That shrinkwrap demo is really impressive β€” finds the exact minimum width for multiline text.", sent: false }, + { text: "ΩƒΩ„ شيؑ! Mixed bidi, grapheme clusters, whatever you want.", sent: true }, + { text: "How does it handle long messages with lots of wrapping?", sent: false }, +] + +export default function ChatBubbleComparison() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return
+ + return ( +
+
+

+ CSS fit-content (dead space) +

+
+ +
+
+
+

+ Pretext shrinkwrap (pixel-tight) +

+
+ +
+
+
+ ) +} diff --git a/apps/docs/registry/chat-bubble/examples/chat-bubble-demo.tsx b/apps/docs/registry/chat-bubble/examples/chat-bubble-demo.tsx new file mode 100644 index 0000000..aa9862d --- /dev/null +++ b/apps/docs/registry/chat-bubble/examples/chat-bubble-demo.tsx @@ -0,0 +1,32 @@ +"use client" + +import * as React from "react" +import { ChatThread, type ChatMessage } from "@/registry/chat-bubble/chat-bubble" + +const messages: ChatMessage[] = [ + { text: "Hey, did you see the new Pretext library?", sent: false }, + { text: "Yeah! It measures text without the DOM. Pure arithmetic.", sent: true }, + { + text: "That shrinkwrap demo is wild β€” it finds the exact minimum width for multiline text. CSS can't do that.", + sent: false, + }, + { text: "μ„±λŠ₯ μ΅œμ ν™”κ°€ 정말 많이 λ˜μ—ˆλ”λΌκ³ μš” πŸŽ‰", sent: true }, + { text: "Oh wow it handles CJK and emoji too??", sent: false }, + { + text: "Everything. Mixed bidi, grapheme clusters, whatever you throw at it.", + sent: true, + timestamp: "2:34 PM", + }, +] + +export default function ChatBubbleDemo() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return
+ + return ( +
+ +
+ ) +} diff --git a/apps/docs/registry/masonry-grid/examples/masonry-grid-demo.tsx b/apps/docs/registry/masonry-grid/examples/masonry-grid-demo.tsx new file mode 100644 index 0000000..9e6cb5a --- /dev/null +++ b/apps/docs/registry/masonry-grid/examples/masonry-grid-demo.tsx @@ -0,0 +1,27 @@ +"use client" + +import * as React from "react" +import { MasonryGrid, type MasonryItem } from "@/registry/masonry-grid/masonry-grid" + +const items: MasonryItem[] = [ + { title: "Quick thought", text: "Every great product started as someone's side project that got a little out of hand." }, + { text: "The best code is the code you didn't have to write. The second best is the code that's obvious enough you don't need comments." }, + { title: "Design systems", text: "A design system isn't about making everything look the same. It's about making everything feel intentional." }, + { text: "Shipping beats perfection. But shipping garbage beats nothing." }, + { title: "On debugging", text: "The bug is never where you think it is. It's in the code you wrote three weeks ago that you were absolutely sure was correct." }, + { text: "Good abstractions are discovered, not invented. Build the thing three times before you abstract it." }, + { title: "Typography", text: "If your UI looks wrong, it's probably a spacing issue. If it still looks wrong, it's a font-weight issue." }, + { text: "The hardest part of building a component library is saying no to the eighth variant." }, + { title: "Performance", text: "Users don't care about your framework choice. They care about whether the button works when they tap it." }, + { text: "Documentation is the feature nobody asks for but everyone needs." }, + { title: "Testing", text: "Write tests for the things that would embarrass you if they broke in production." }, + { text: "CSS is easy until it isn't, and then it's the hardest thing in the world." }, +] + +export default function MasonryGridDemo() { + const [mounted, setMounted] = React.useState(false) + React.useEffect(() => { setMounted(true) }, []) + if (!mounted) return
+ + return +} diff --git a/apps/docs/registry/masonry-grid/masonry-grid.tsx b/apps/docs/registry/masonry-grid/masonry-grid.tsx new file mode 100644 index 0000000..0d605b1 --- /dev/null +++ b/apps/docs/registry/masonry-grid/masonry-grid.tsx @@ -0,0 +1,158 @@ +/** + * jalco-ui + * MasonryGrid + * by Justin Levine + * ui.justinlevine.me + * + * Text-aware masonry layout where card heights are predicted by Pretext + * without DOM measurement. Cards flow into columns with zero layout + * shift, positioned using pure arithmetic. + * + * Dependencies: @chenglou/pretext (via pretext registry item) + * + * Powered by Pretext by Cheng Lou β€” github.com/chenglou/pretext + */ + +"use client" + +import * as React from "react" +import { cn } from "@/lib/utils" +import type { PreparedText } from "@chenglou/pretext" + +const isBrowser = typeof window !== "undefined" + +function getPretext() { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("@chenglou/pretext") as typeof import("@chenglou/pretext") +} + +interface MasonryItem { + /** Text content of the card. */ + text: string + /** Optional title above the text. */ + title?: string +} + +interface MasonryGridProps extends Omit, "children"> { + /** Array of items to display. */ + items: MasonryItem[] + /** Number of columns. @default 3 */ + columns?: 2 | 3 | 4 + /** Gap between cards in pixels. @default 12 */ + gap?: number + /** CSS font shorthand for body text measurement. Must match rendered font. + * @default '14px/20px ui-sans-serif, system-ui, sans-serif' */ + font?: string + /** Body text line height in pixels. @default 20 */ + lineHeight?: number + /** Card padding in pixels. @default 16 */ + cardPadding?: number +} + +const DEFAULT_FONT = "14px ui-sans-serif, system-ui, sans-serif" +const DEFAULT_LINE_HEIGHT = 20 +const DEFAULT_CARD_PADDING = 16 +const TITLE_HEIGHT = 28 + +function MasonryGrid({ + items, + columns = 3, + gap = 12, + font = DEFAULT_FONT, + lineHeight = DEFAULT_LINE_HEIGHT, + cardPadding = DEFAULT_CARD_PADDING, + className, + ...props +}: MasonryGridProps) { + const containerRef = React.useRef(null) + const [containerWidth, setContainerWidth] = React.useState(0) + + React.useEffect(() => { + const el = containerRef.current + if (!el) return + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerWidth(entry.contentRect.width) + } + }) + observer.observe(el) + setContainerWidth(el.clientWidth) + return () => observer.disconnect() + }, []) + + const prepared = React.useMemo( + () => { + if (!isBrowser) return [] as PreparedText[] + const { prepare } = getPretext() + return items.map((item) => prepare(item.text, font)) + }, + [items, font], + ) + + const positioned = React.useMemo(() => { + if (containerWidth <= 0) return { cards: [] as { x: number; y: number; w: number; h: number; index: number }[], height: 0 } + + const colWidth = (containerWidth - (columns - 1) * gap) / columns + const textWidth = Math.max(1, colWidth - cardPadding * 2) + const colHeights = new Array(columns).fill(0) as number[] + + const cards = items.map((item, i) => { + let shortest = 0 + for (let c = 1; c < columns; c++) { + if (colHeights[c]! < colHeights[shortest]!) shortest = c + } + + const { height: textHeight } = getPretext().layout(prepared[i]!, textWidth, lineHeight) + const titleExtra = item.title ? TITLE_HEIGHT : 0 + const cardHeight = textHeight + titleExtra + cardPadding * 2 + + const x = shortest * (colWidth + gap) + const y = colHeights[shortest]! + + colHeights[shortest]! += cardHeight + gap + + return { x, y, w: colWidth, h: cardHeight, index: i } + }) + + const height = Math.max(...colHeights) + return { cards, height } + }, [items, prepared, containerWidth, columns, gap, cardPadding, lineHeight]) + + return ( +
+ {containerWidth > 0 && + positioned.cards.map((card) => { + const item = items[card.index]! + return ( +
+ {item.title && ( +

+ {item.title} +

+ )} +

+ {item.text} +

+
+ ) + })} +
+ ) +} + +export { MasonryGrid, type MasonryGridProps, type MasonryItem } diff --git a/apps/docs/registry/pretext/examples/pretext-demo.tsx b/apps/docs/registry/pretext/examples/pretext-demo.tsx new file mode 100644 index 0000000..0fcd939 --- /dev/null +++ b/apps/docs/registry/pretext/examples/pretext-demo.tsx @@ -0,0 +1,88 @@ +"use client" + +import * as React from "react" +import { + usePretextWithSegments, + usePretextLayout, + useShrinkwrap, + useBalancedWidth, +} from "@/registry/pretext/use-pretext" + +const SAMPLE_TEXT = + "Pretext measures text without the DOM. Pure arithmetic, accurate for CJK, Arabic, emoji, and everything else." +const FONT = "14px ui-sans-serif, system-ui, sans-serif" +const LINE_HEIGHT = 20 + +export default function PretextDemo() { + const [mounted, setMounted] = React.useState(false) + const [width, setWidth] = React.useState(280) + React.useEffect(() => { setMounted(true) }, []) + + const prepared = usePretextWithSegments(SAMPLE_TEXT, FONT) + const layoutResult = usePretextLayout(prepared, width, LINE_HEIGHT) + const shrinkwrapped = useShrinkwrap(prepared, width) + const balanced = useBalancedWidth(prepared, width) + + if (!mounted) return
+ + return ( +
+
+ + setWidth(Number(e.target.value))} + className="flex-1" + /> + + {width}px + +
+ +
+
+

{layoutResult.lineCount}

+

Lines

+
+
+

{shrinkwrapped}

+

Shrinkwrap

+
+
+

{balanced}

+

Balanced

+
+
+ +
+
+

+ Max width ({width}px) +

+

+ {SAMPLE_TEXT} +

+
+
+

+ Shrinkwrap ({shrinkwrapped}px) +

+

+ {SAMPLE_TEXT} +

+
+
+
+ ) +} diff --git a/apps/docs/registry/pretext/use-pretext.ts b/apps/docs/registry/pretext/use-pretext.ts new file mode 100644 index 0000000..3d96928 --- /dev/null +++ b/apps/docs/registry/pretext/use-pretext.ts @@ -0,0 +1,188 @@ +/** + * jalco-ui + * usePretext hooks + * by Justin Levine + * ui.justinlevine.me + * + * React hooks wrapping @chenglou/pretext for DOM-free text measurement. + * Provides prepare/layout lifecycle management, shrinkwrap search, + * and line-balanced width computation. + * + * Powered by Pretext by Cheng Lou β€” github.com/chenglou/pretext + */ + +"use client" + +import * as React from "react" +import type { + PreparedText, + PreparedTextWithSegments, + LayoutResult, + LayoutLinesResult, + PrepareOptions, +} from "@chenglou/pretext" + +export type { PreparedText, PreparedTextWithSegments, LayoutResult, LayoutLinesResult, PrepareOptions } + +const isBrowser = typeof window !== "undefined" + +function getPretext() { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require("@chenglou/pretext") as typeof import("@chenglou/pretext") +} + +const EMPTY_LAYOUT: LayoutResult = { lineCount: 0, height: 0 } +const EMPTY_LINES: LayoutLinesResult = { lineCount: 0, height: 0, lines: [] } + +/** + * Prepare text for Pretext measurement. Runs `prepare()` once and caches + * the result until `text`, `font`, or `options` change. + * + * The returned handle is opaque β€” pass it to `usePretextLayout`. + */ +export function usePretext( + text: string, + font: string, + options?: PrepareOptions, +): PreparedText | null { + const whiteSpace = options?.whiteSpace ?? "normal" + + return React.useMemo(() => { + if (!isBrowser) return null + return getPretext().prepare(text, font, options) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text, font, whiteSpace]) +} + +/** + * Prepare text with segment data for advanced layout (line-by-line rendering, + * shrinkwrap, balancing). Same as `usePretext` but returns the richer handle. + */ +export function usePretextWithSegments( + text: string, + font: string, + options?: PrepareOptions, +): PreparedTextWithSegments | null { + const whiteSpace = options?.whiteSpace ?? "normal" + + return React.useMemo(() => { + if (!isBrowser) return null + return getPretext().prepareWithSegments(text, font, options) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [text, font, whiteSpace]) +} + +/** + * Layout prepared text at a given width and line height. Pure arithmetic β€” + * no DOM reads. Returns line count and total height. + * + * Re-runs on every `maxWidth` or `lineHeight` change (~0.0002ms). + */ +export function usePretextLayout( + prepared: PreparedText | null, + maxWidth: number, + lineHeight: number, +): LayoutResult { + return React.useMemo(() => { + if (!prepared) return EMPTY_LAYOUT + return getPretext().layout(prepared, maxWidth, lineHeight) + }, [prepared, maxWidth, lineHeight]) +} + +/** + * Layout prepared text and return full line data (text, width, cursors). + * Heavier than `usePretextLayout` β€” use when you need per-line info + * for custom rendering. + */ +export function usePretextLines( + prepared: PreparedTextWithSegments | null, + maxWidth: number, + lineHeight: number, +): LayoutLinesResult { + return React.useMemo(() => { + if (!prepared) return EMPTY_LINES + return getPretext().layoutWithLines(prepared, maxWidth, lineHeight) + }, [prepared, maxWidth, lineHeight]) +} + +/** + * Find the tightest width that produces the same line count as `maxWidth`. + * Binary-searches widths using `walkLineRanges` β€” no DOM measurement. + * + * Returns the shrinkwrapped width in pixels. + */ +export function useShrinkwrap( + prepared: PreparedTextWithSegments | null, + maxWidth: number, +): number { + return React.useMemo(() => { + if (!prepared || maxWidth <= 0) return 0 + + const { walkLineRanges } = getPretext() + + let baseLineCount = 0 + walkLineRanges(prepared, maxWidth, () => { baseLineCount++ }) + if (baseLineCount <= 1) { + let singleLineWidth = 0 + walkLineRanges(prepared, maxWidth, (line) => { singleLineWidth = line.width }) + return Math.ceil(singleLineWidth) || 0 + } + + let lo = 1 + let hi = Math.ceil(maxWidth) + + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + let midLineCount = 0 + walkLineRanges(prepared, mid, () => { midLineCount++ }) + + if (midLineCount <= baseLineCount) { + hi = mid + } else { + lo = mid + 1 + } + } + + return lo + }, [prepared, maxWidth]) +} + +/** + * Find the width where all lines are roughly equal length (balanced text). + * Binary-searches for the narrowest width that keeps the same line count + * as `maxWidth`, then returns that width. + * + * CSS `text-wrap: balance` only works up to ~6 lines and is inconsistent + * cross-browser. This works on any length and is deterministic. + */ +export function useBalancedWidth( + prepared: PreparedTextWithSegments | null, + maxWidth: number, +): number { + return React.useMemo(() => { + if (!prepared || maxWidth <= 0) return 0 + + const { walkLineRanges } = getPretext() + + let baseLineCount = 0 + walkLineRanges(prepared, maxWidth, () => { baseLineCount++ }) + if (baseLineCount <= 1) return maxWidth + + let lo = 1 + let hi = Math.ceil(maxWidth) + + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2) + let midLineCount = 0 + walkLineRanges(prepared, mid, () => { midLineCount++ }) + + if (midLineCount <= baseLineCount) { + hi = mid + } else { + lo = mid + 1 + } + } + + return lo + }, [prepared, maxWidth]) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8f30f85..d449e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: apps/docs: dependencies: + '@chenglou/pretext': + specifier: ^0.0.4 + version: 0.0.4 '@orama/orama': specifier: ^3.1.18 version: 3.1.18 @@ -297,6 +300,9 @@ packages: '@bundled-es-modules/tough-cookie@0.1.6': resolution: {integrity: sha512-dvMHbL464C0zI+Yqxbz6kZ5TOEp7GLW+pry/RWndAR8MJQAXZ2rPmIs8tziTZjeIyhSNZgZbCePtfSbdWqStJw==} + '@chenglou/pretext@0.0.4': + resolution: {integrity: sha512-FnPAFMid1/p1j2V2gRPUVBarGUIb2PhkkC9YNnTOfPtTDgHKh8siO8PP9pCxpFfYlcodWPJpE1UbSHGQqt8pQQ==} + '@dotenvx/dotenvx@1.49.0': resolution: {integrity: sha512-M1cyP6YstFQCjih54SAxCqHLMMi8QqV8tenpgGE48RTXWD7vfMYJiw/6xcCDpS2h28AcLpTsFCZA863Ge9yxzA==} hasBin: true @@ -5145,6 +5151,8 @@ snapshots: '@types/tough-cookie': 4.0.5 tough-cookie: 4.1.4 + '@chenglou/pretext@0.0.4': {} + '@dotenvx/dotenvx@1.49.0': dependencies: commander: 11.1.0