From 8216e18c524c6b8f80ae27ee3b25463f190181a1 Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 26 Jul 2026 19:42:23 +0700 Subject: [PATCH 1/3] fix: declare tailwind-merge as a runtime dependency 127 shipped dist files `import { twMerge } from "tailwind-merge"`, but the package declared it only as a devDependency. A consumer installing @pathscale/ui into a project that does not already have tailwind-merge gets a runtime failure on first import: Cannot find package 'tailwind-merge' from .../@pathscale/ui/dist/components/button-group/ButtonGroup.js It went unnoticed because every app in this org installs tailwind-merge itself, so the import resolves from the app's own node_modules. A fresh consumer has nothing to resolve it from. Found by the consumer smoke test on its first run, against the tarball published as 1.3.0 twenty minutes earlier. Co-Authored-By: Claude Opus 5 --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 94ad86b..edb873b 100644 --- a/package.json +++ b/package.json @@ -89,14 +89,14 @@ "postcss-selector-parser": "^7.1.1", "solid-js": "^1.9.13", "svgo": "^3.3.3", - "tailwind-merge": "^3.6.0", "typescript": "^6.0.3" }, "dependencies": { "@iconify/tailwind4": "^1.2.3", "@pathscale/rsbuild-plugin-iconify": "^1.0.4", "@pathscale/ui": "^1.2.10", - "@tanstack/solid-virtual": "^3.13.27" + "@tanstack/solid-virtual": "^3.13.27", + "tailwind-merge": "^3.6.0" }, "peerDependencies": { "@solid-primitives/event-listener": "^2.3.0", From fb6695195d16be141ad2db941ca67bd4509f9b0e Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 26 Jul 2026 19:42:23 +0700 Subject: [PATCH 2/3] fix: correct the documented Button API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ui-usage.md and the README both showed + ); ``` @@ -81,8 +81,12 @@ The rules that hold across every component — worth two minutes before your fir - **Booleans are HeroUI-style `is*`**: `isDisabled`, `isOpen`, `isInvalid`, `isPending`, `isIconOnly`. Native `disabled` is honored too. -- **Sizes** are `xs | sm | md | lg | xl`. **Colors** are - `neutral | primary | secondary | accent | info | success | warning | error | ghost`. +- **Sizes and variants are per-component.** `Button` takes + `variant` (`primary | secondary | tertiary | outline | ghost | danger | danger-soft`) + and `size` (`sm | md | lg`); a smaller set of components — `Badge`, `Chip`, `Avatar`, + `Spinner`, `Toggle`, the progress components — take `color` instead. Check the + component's own types, or the [showcase](https://js.software), rather than assuming a + shared union. - **Both `class` and `className` work** everywhere, and your classes win — they are merged last via twMerge. - **Controlled/uncontrolled come in triples**: `isOpen/defaultOpen/onOpenChange`, diff --git a/docs/ui-usage.md b/docs/ui-usage.md index 694dda7..4a0887b 100644 --- a/docs/ui-usage.md +++ b/docs/ui-usage.md @@ -25,11 +25,6 @@ import "@pathscale/ui/index.css"; // tokens + theme Subpath exports also exist: `./components/*`, `./primitives/*`, `./hooks/*`, `./motion`, `./styles/*`. -> ⚠️ The README still references two paths that do not exist: -> `dist/styles/compat/daisy-primitives.css` and `docs/motion.md`. Ignore both until the -> README is corrected. *(The previously-documented broken `@pathscale/ui/stores` export -> was removed in `85e1633` and is no longer a concern.)* - ## Theming - Two themes: `light` (default when no attribute) and `dark`. Switch: `document.documentElement.setAttribute("data-theme", "dark")`. @@ -40,7 +35,13 @@ Subpath exports also exist: `./components/*`, `./primitives/*`, `./hooks/*`, `./ ## Component conventions (consumer-facing) - Booleans are HeroUI-style `is*`: `isDisabled`, `isOpen`, `isInvalid`, `isPending`, `isIconOnly`, `isHoverable`, `isPressable`. Native `disabled` also honored. -- Sizes: `xs | sm | md | lg | xl` (`ComponentSize`). Colors: `neutral | primary | secondary | accent | info | success | warning | error | ghost` (`ComponentColor`). +- Sizes and colour-ish props are **per-component, not a shared union in practice**. + `ComponentSize` and `ComponentColor` are declared in `src/components/types.ts` with the + full unions, but **they are not re-exported from the root barrel**, so consumers cannot + import them, and individual components narrow them. `Button` takes + `variant` (`primary | secondary | tertiary | outline | ghost | danger | danger-soft`) + and `size` (`sm | md | lg`); `Badge`, `Chip`, `Avatar`, `Spinner`, `Toggle`, `Meter` and + the progress components take `color`. Read the component's own props before assuming. - Both `class` and `className` accepted everywhere; consumer classes win (merged last via twMerge). - Controlled/uncontrolled triples: `isOpen/defaultOpen/onOpenChange`, `value/defaultValue/onChange`, `selectedKey/defaultSelectedKey/onSelectionChange`. Event callbacks pass **values, not events**. - Compound components: `Modal.Trigger`, `Tabs.List`, `Select.Option`, etc. (`Object.assign` statics; also exported flat: `AccordionRoot`, `AlertTitle`, …). Parts are styleable/testable via `data-slot="..."` and state attrs (`data-open`, `data-selected`, `data-invalid`). @@ -48,7 +49,7 @@ Subpath exports also exist: `./components/*`, `./primitives/*`, `./hooks/*`, `./ ```tsx - + ``` From 85614f1600e0e6a357a425eff21ae4bf24ccce0a Mon Sep 17 00:00:00 2001 From: meh Date: Sun, 26 Jul 2026 19:42:23 +0700 Subject: [PATCH 3/3] ci: replace attw with a consumer smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @arethetypeswrong/cli modelled four TypeScript resolution modes, three of which nothing here ships to, and reported 378 findings. The split was clean: 200 extensionless-import errors under node16 only, and 178 CSS side-effect imports under node10 and bundler. Every consumer app in this org sets skipLibCheck: true, so none of it was ever felt. Zero actionable findings. This installs the packed tarball into a fixture app configured the way those apps are — bun, moduleResolution: bundler, solid-js, peer deps derived from the manifest — then typechecks it and imports it at runtime. On its first run it caught tailwind-merge being a devDependency while 127 shipped files import it, and that Button takes `variant` rather than the documented `color`. attw found neither in 378 lines of output. Advisory for now. Today's release failed four times on gates that had never executed in CI; this one gets promoted to blocking only after it has run green a few times. Co-Authored-By: Claude Opus 5 --- .github/workflows/release.yml | 17 ++- package.json | 1 + scripts/smoke-consumer.ts | 209 ++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 scripts/smoke-consumer.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a33261f..1f7a6b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -135,13 +135,18 @@ jobs: - name: publint (publish config, exports, file paths) run: npx --yes publint --strict - # Advisory, not blocking. The published 1.2.11 fails this identically, so it - # reports a pre-existing condition rather than a regression, and gating on it - # would block releases that are strictly better than what is live. The .d.ts - # internal-resolution problem is real and worth fixing on its own. - - name: Are the types wrong? (advisory) + # Replaces @arethetypeswrong/cli, which modelled four TypeScript resolution + # modes — three of which nothing here ships to — and reported 378 findings + # with none actionable. This installs the packed tarball into a fixture app + # configured like our real consumers and typechecks it, which on its first + # run caught tailwind-merge being a devDependency while 127 shipped files + # import it. + # + # Advisory for now; promote to blocking once it has run green a few times. + # Today's release failed four times on gates that had never run in CI. + - name: Consumer smoke test (advisory) continue-on-error: true - run: npx --yes @arethetypeswrong/cli --pack --ignore-rules cjs-resolves-to-esm + run: bun run smoke - name: Publish to npm run: npm publish --provenance --access public diff --git a/package.json b/package.json index edb873b..5de1f49 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,7 @@ "check": "bun run scripts/check-contracts.ts", "check:package": "bun run scripts/check-package.ts", "next-version": "bun run scripts/next-version.ts", + "smoke": "bun run scripts/smoke-consumer.ts", "playground:dev": "cd playground && bun run dev", "playground:build": "cd playground && bun run build", "playground:preview": "cd playground && bun run preview" diff --git a/scripts/smoke-consumer.ts b/scripts/smoke-consumer.ts new file mode 100644 index 0000000..6951dbc --- /dev/null +++ b/scripts/smoke-consumer.ts @@ -0,0 +1,209 @@ +/** + * Consumer smoke test: install the packed tarball into a throwaway app configured + * the way real consumers are, then typecheck and load it. + * + * This replaces @arethetypeswrong/cli, which modelled four TypeScript resolution + * modes — three of which nothing here ships to — and produced 378 findings with + * none actionable. This tests the one configuration that matters instead. + * + * What it proves: + * - the published tarball installs + * - types resolve under `moduleResolution: "bundler"`, which is what our apps use + * - declared exports actually exist and are importable at runtime + * - subpath exports resolve (this is how a stale `./stores` would be caught) + * + * What it does not prove: that components render. That needs the Solid JSX + * pipeline, and a flaky gate is worse than a narrow one. + * + * Run: bun run smoke + * SMOKE_TARBALL=/path/to/pkg.tgz bun run smoke # test a specific tarball + */ +import { execSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const root = process.cwd(); +const pkgJson = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + +const run = (cmd: string, cwd: string) => + execSync(cmd, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + +// ------------------------------------------------------------------ tarball + +const tarballPath = (): string => { + const override = process.env.SMOKE_TARBALL; + if (override) { + const p = resolve(override); + if (!existsSync(p)) throw new Error(`SMOKE_TARBALL not found: ${p}`); + return p; + } + const expected = `${String(pkgJson.name).replace(/^@/, "").replace(/\//g, "-")}-${pkgJson.version}.tgz`; + const stdout = execSync("npm pack --silent", { cwd: root, encoding: "utf8" }); + if (existsSync(join(root, expected))) return join(root, expected); + const printed = stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.endsWith(".tgz")) + .pop(); + if (printed && existsSync(join(root, printed))) return join(root, printed); + throw new Error(`npm pack produced no tarball (expected ${expected})`); +}; + +const tarball = tarballPath(); +const packedHere = !process.env.SMOKE_TARBALL; + +// ------------------------------------------------------------------ fixture + +const fixture = mkdtempSync(join(tmpdir(), "ui-smoke-")); +mkdirSync(join(fixture, "src"), { recursive: true }); + +// Peer deps a consumer must install, minus the optional ones. Derived from the +// manifest so this cannot drift from what we declare. +const optional = new Set(Object.keys(pkgJson.peerDependenciesMeta ?? {})); +const peers = Object.fromEntries( + Object.entries(pkgJson.peerDependencies ?? {}).filter(([name]) => !optional.has(name)), +); + +writeFileSync( + join(fixture, "package.json"), + JSON.stringify( + { + name: "ui-consumer-smoke", + private: true, + type: "module", + dependencies: { ...peers, [pkgJson.name]: `file:${tarball}` }, + devDependencies: { typescript: pkgJson.devDependencies?.typescript ?? "^5" }, + }, + null, + 2, + ), +); + +// Mirrors how our apps are configured: bundler resolution, Solid JSX. +writeFileSync( + join(fixture, "tsconfig.json"), + JSON.stringify( + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + jsx: "preserve", + jsxImportSource: "solid-js", + strict: true, + noEmit: true, + // true, matching every consumer app in this org. With false, the CSS + // side-effect imports inside the shipped .d.ts files raise 178 TS2882 + // errors that no real consumer experiences — noise, not signal. + skipLibCheck: true, + types: [], + }, + include: ["src"], + }, + null, + 2, + ), +); + +// A spread wide enough to touch the root barrel, compound components, hooks, +// exported types and a subpath export. +writeFileSync( + join(fixture, "src/consumer.tsx"), + `import { + Button, + Card, + Flex, + Modal, + Select, + Table, + Toast, + toast, + createForm, + useTableModel, + type ComponentColor, + type ComponentSize, +} from "${pkgJson.name}"; +import { runMotion } from "${pkgJson.name}/motion"; + +const color: ComponentColor = "primary"; +const size: ComponentSize = "md"; + +export const App = () => ( + + + + body + + +); + +// Values must exist, not just types. +export const used = [Modal, Select, Table, Toast, toast, createForm, useTableModel, runMotion]; +`, +); + +// Runtime load: proves the ESM actually resolves and the barrel is populated. +writeFileSync( + join(fixture, "src/load.mjs"), + `const mod = await import("${pkgJson.name}"); +const expected = ["Button", "Flex", "Card", "Modal", "toast", "createForm", "useTableModel"]; +const missing = expected.filter((k) => mod[k] === undefined); +if (missing.length) { + console.error("missing exports from the root barrel: " + missing.join(", ")); + process.exit(1); +} +const motion = await import("${pkgJson.name}/motion"); +if (typeof motion.runMotion !== "function") { + console.error("./motion did not export runMotion"); + process.exit(1); +} +console.log("runtime load ok — " + Object.keys(mod).length + " root exports"); +`, +); + +// ------------------------------------------------------------------ execute + +let failed = false; +const step = (label: string, fn: () => string) => { + try { + const out = fn(); + console.log(`✔ ${label}`); + const tail = out.trim().split("\n").slice(-1)[0]; + if (tail) console.log(` ${tail}`); + } catch (err: unknown) { + failed = true; + const e = err as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string }; + console.error(`✖ ${label}`); + const detail = String(e.stdout ?? "") + String(e.stderr ?? "") || e.message || ""; + console.error( + detail + .trim() + .split("\n") + .slice(0, 25) + .map((l) => ` ${l}`) + .join("\n"), + ); + } +}; + +console.log(`consumer smoke test\n tarball: ${tarball}\n fixture: ${fixture}\n`); + +step("install the tarball into a fresh consumer", () => run("bun install", fixture)); +step("typecheck with moduleResolution: bundler", () => + run("./node_modules/.bin/tsc --noEmit", fixture), +); +step("import the package at runtime", () => run("bun src/load.mjs", fixture)); + +// ------------------------------------------------------------------ cleanup + +rmSync(fixture, { recursive: true, force: true }); +if (packedHere) rmSync(tarball, { force: true }); + +if (failed) { + console.error("\n✖ consumer smoke test failed — a real app would hit this\n"); + process.exit(1); +} +console.log("\n✔ consumer smoke test passed");