Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import "@pathscale/ui/index.css";

export const Example = () => (
<Flex direction="col" gap="sm">
<Button color="primary" size="md">Primary</Button>
<Button variant="primary" size="md">Primary</Button>
</Flex>
);
```
Expand Down Expand Up @@ -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`,
Expand Down
15 changes: 8 additions & 7 deletions docs/ui-usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")`.
Expand All @@ -40,15 +35,21 @@ 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`).
- No polymorphic `as` prop.

```tsx
<Flex direction="col" gap="sm">
<Button color="primary" size="md" isPending={saving()}>Save</Button>
<Button variant="primary" size="md" isPending={saving()}>Save</Button>
</Flex>
```

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"
Expand Down
209 changes: 209 additions & 0 deletions scripts/smoke-consumer.ts
Original file line number Diff line number Diff line change
@@ -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 = () => (
<Flex direction="col" gap="sm">
<Button color={color} size={size} isDisabled={false}>
Save
</Button>
<Card>
<Card.Body>body</Card.Body>
</Card>
</Flex>
);

// 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");
Loading