@kyzarky/ui 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # @kyzarky/ui
2
2
 
3
- Design tokens for Ky Merritt's apps: a Tailwind CSS 4 theme with light and dark values.
4
- React components arrive in 0.2.0 ([ADR 0002](docs/decisions/0002-first-scope.md)). First
5
- consumer: Fluent Prior. Next: Merritt's Mini Mind.
3
+ Design tokens and React components for Ky Merritt's apps: a Tailwind CSS 4 theme with
4
+ light and dark values, and the components built on it
5
+ ([ADR 0002](docs/decisions/0002-first-scope.md)). First consumer: Fluent Prior. Next:
6
+ Merritt's Mini Mind.
6
7
 
7
8
  ## Use
8
9
 
@@ -17,7 +18,9 @@ Then import the theme after Tailwind in the app's CSS:
17
18
  @import "@kyzarky/ui/theme.css";
18
19
  ```
19
20
 
20
- It needs Tailwind CSS 4. **Pin an exact version and upgrade on purpose,** so work in
21
+ It needs Tailwind CSS 4, and React 19 for the components. The import also has the app's
22
+ Tailwind scan the package's components, so their classes are generated with the app's own.
23
+ **Pin an exact version and upgrade on purpose,** so work in
21
24
  progress here never changes an app under it. The [changelog](CHANGELOG.md) says what each
22
25
  version changes and how to upgrade.
23
26
 
@@ -78,6 +81,56 @@ own rule wins wherever it is. For example, a green brand color:
78
81
  `ring` follows `primary` unless you set it too. Keep the contrast of every pair in the
79
82
  table above.
80
83
 
84
+ ## Components
85
+
86
+ ```tsx
87
+ import { List, ListRow, Meter } from "@kyzarky/ui";
88
+
89
+ <List heading={<h3>Grammar</h3>} description="Optional help text.">
90
+ <ListRow label="Question word order" detail="99% · 9 answers">
91
+ <Meter value={0.99} />
92
+ </ListRow>
93
+ </List>
94
+ ```
95
+
96
+ - **`List`** is a group of rows with a divider between them. Pass the `heading` as an
97
+ element, at whatever level the page needs; `List` sets its look. A `List` that follows
98
+ something sits 32px below it.
99
+ - **`ListRow`** is one row: a `label`, a `detail` on the right, and optional children
100
+ below for a graphic.
101
+ - **`Meter`** is a bar for a `value` from 0 to 1. It's hidden from screen readers, so
102
+ state the value in the row's `detail`.
103
+
104
+ ```tsx
105
+ import { Button, Card, SegmentedControl } from "@kyzarky/ui";
106
+
107
+ <Card aria-labelledby="progress-heading" className="flex flex-col gap-4">
108
+ <SegmentedControl
109
+ label="Show"
110
+ value={view}
111
+ onChange={setView}
112
+ options={[
113
+ { value: "now", label: "Now" },
114
+ { value: "over-time", label: "Over time" },
115
+ ]}
116
+ />
117
+ <Button type="submit">Check my answer</Button>
118
+ <Button variant="outline" onClick={next}>Next situation</Button>
119
+ </Card>
120
+ ```
121
+
122
+ - **`Card`** is a raised surface, rendered as a `<section>`. Name it with
123
+ `aria-labelledby` or `aria-label`.
124
+ - **`Button`** takes shadcn/ui's props: `variant` is `default` (filled), `outline`, or
125
+ `ghost`, and `size` is `default` or `icon`. Unlike a bare `<button>`, its `type` is
126
+ `button` unless you pass `submit`. It also takes every native button prop.
127
+ - **`SegmentedControl`** picks one option from a few. It's a group of radio buttons
128
+ underneath, so arrow keys move between options. `label` names the group for screen
129
+ readers and isn't shown.
130
+
131
+ `Card` and `Button` take a `className` for layout only (`flex-1`, `w-full`, a `gap`). It's
132
+ added after the component's own classes, not merged with them, so don't use it to restyle.
133
+
81
134
  ## Rules
82
135
 
83
136
  - **Less space inside a group than between groups,** at least 1:2.
@@ -111,8 +164,9 @@ table above.
111
164
 
112
165
  | Command | What it does |
113
166
  |---|---|
114
- | `npm run verify` | Typecheck, lint, and test |
115
- | `npm test` | The contrast and fallback tests, and a Tailwind build of the theme |
167
+ | `npm run verify` | Typecheck, lint, test, and build |
168
+ | `npm run build` | Compiles `src` to `dist` with `tsc`. `npm pack` and `npm publish` run it first |
169
+ | `npm test` | The contrast, fallback, and component tests, and a Tailwind build of the theme |
116
170
  | `npm run test:browsers` | Opens `browser-check.html` in Chromium, Firefox, and WebKit, in light and dark. Needs `npx playwright install` once. Add engine names to run only those: `npm run test:browsers -- webkit` |
117
171
  | `npm run format` | Biome: format and apply safe lint fixes |
118
172
 
@@ -0,0 +1,20 @@
1
+ import type { ComponentProps } from "react";
2
+ declare const VARIANTS: {
3
+ /** Filled with the brand color: the main action. */
4
+ default: string;
5
+ /** An edge and the card color: a second action beside a filled one. */
6
+ outline: string;
7
+ /** Nothing until hovered: a quiet action, such as an icon in a toolbar. */
8
+ ghost: string;
9
+ };
10
+ declare const SIZES: {
11
+ default: string;
12
+ /** Square, for a single icon. Give the button an aria-label. */
13
+ icon: string;
14
+ };
15
+ interface ButtonProps extends ComponentProps<"button"> {
16
+ variant?: keyof typeof VARIANTS;
17
+ size?: keyof typeof SIZES;
18
+ }
19
+ export declare function Button({ variant, size, type, className, ...props }: ButtonProps): import("react").JSX.Element;
20
+ export {};
package/dist/Button.js ADDED
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { joinClasses } from "./joinClasses.js";
3
+ const VARIANTS = {
4
+ /** Filled with the brand color: the main action. */
5
+ default: "bg-primary text-primary-foreground hover:bg-primary/90 active:bg-primary/80",
6
+ /** An edge and the card color: a second action beside a filled one. */
7
+ outline: "border border-input bg-card hover:bg-muted active:bg-muted",
8
+ /** Nothing until hovered: a quiet action, such as an icon in a toolbar. */
9
+ ghost: "hover:bg-muted active:bg-muted",
10
+ };
11
+ const SIZES = {
12
+ default: "px-5",
13
+ /** Square, for a single icon. Give the button an aria-label. */
14
+ icon: "w-11",
15
+ };
16
+ export function Button({ variant = "default", size = "default",
17
+ // A bare <button> inside a form submits it. Submitting should be asked for.
18
+ type = "button", className, ...props }) {
19
+ return (_jsx("button", { type: type, className: joinClasses("inline-flex min-h-11 items-center justify-center gap-2 rounded-xl font-semibold transition-colors focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2 disabled:pointer-events-none disabled:opacity-50", VARIANTS[variant], SIZES[size], className), ...props }));
20
+ }
package/dist/Card.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { ComponentProps } from "react";
2
+ export declare function Card({ className, ...props }: ComponentProps<"section">): import("react").JSX.Element;
package/dist/Card.js ADDED
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { joinClasses } from "./joinClasses.js";
3
+ export function Card({ className, ...props }) {
4
+ return (_jsx("section", { className: joinClasses("rounded-2xl border border-border bg-card p-5 text-card-foreground shadow-sm", className), ...props }));
5
+ }
package/dist/List.d.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { ReactNode } from "react";
2
+ interface ListProps {
3
+ /**
4
+ * The heading element, at whatever level the page needs: `<h3>Grammar</h3>`. The level
5
+ * is the page's to choose; the look is set here. Tailwind's preflight makes headings
6
+ * inherit size and weight, so the wrapper's classes apply to it.
7
+ */
8
+ heading?: ReactNode;
9
+ /** A line of help text between the heading and the rows. */
10
+ description?: ReactNode;
11
+ /** `ListRow`s. */
12
+ children: ReactNode;
13
+ }
14
+ export declare function List({ heading, description, children }: ListProps): import("react").JSX.Element;
15
+ export {};
package/dist/List.js ADDED
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function List({ heading, description, children }) {
3
+ return (_jsxs("div", { className: "mt-8 first:mt-0", children: [heading && _jsx("div", { className: "font-semibold text-base", children: heading }), description && _jsx("p", { className: "mt-1 text-muted-foreground text-sm", children: description }), _jsx("ul", { className: "mt-2 divide-y divide-border", children: children })] }));
4
+ }
@@ -0,0 +1,10 @@
1
+ import type { ReactNode } from "react";
2
+ interface ListRowProps {
3
+ label: ReactNode;
4
+ /** Secondary text on the right. If the row has a graphic, this states its value. */
5
+ detail?: ReactNode;
6
+ /** A graphic for the row, such as a `Meter`. */
7
+ children?: ReactNode;
8
+ }
9
+ export declare function ListRow({ label, detail, children }: ListRowProps): import("react").JSX.Element;
10
+ export {};
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function ListRow({ label, detail, children }) {
3
+ return (_jsxs("li", { className: "py-3", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { children: label }), detail && _jsx("span", { className: "shrink-0 text-muted-foreground tabular-nums", children: detail })] }), children && _jsx("div", { className: "mt-2", children: children })] }));
4
+ }
@@ -0,0 +1,6 @@
1
+ interface MeterProps {
2
+ /** From 0 to 1. Values outside that are clamped, and NaN draws an empty bar. */
3
+ value: number;
4
+ }
5
+ export declare function Meter({ value }: MeterProps): import("react").JSX.Element;
6
+ export {};
package/dist/Meter.js ADDED
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ export function Meter({ value }) {
3
+ // `|| 0` turns NaN into 0. An invalid width would be ignored, leaving the bar full.
4
+ const percent = Math.min(1, Math.max(0, value || 0)) * 100;
5
+ return (_jsx("div", { "aria-hidden": "true", className: "h-2 overflow-hidden rounded-full bg-muted", children: _jsx("div", { className: "h-full rounded-full bg-primary", style: { width: `${percent}%` } }) }));
6
+ }
@@ -0,0 +1,13 @@
1
+ import { type ReactNode } from "react";
2
+ interface SegmentedControlProps<Value extends string> {
3
+ /** What the choice is, for screen readers: "Show", "Sort by". Not displayed. */
4
+ label: string;
5
+ options: readonly {
6
+ value: Value;
7
+ label: ReactNode;
8
+ }[];
9
+ value: Value;
10
+ onChange: (value: Value) => void;
11
+ }
12
+ export declare function SegmentedControl<Value extends string>({ label, options, value, onChange, }: SegmentedControlProps<Value>): import("react").JSX.Element;
13
+ export {};
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // A choice of one option from a few, shown side by side. Underneath it's a group of radio
3
+ // buttons, so arrow keys move between options and a screen reader says how many there
4
+ // are, with no key handling written here. The selected look comes from CSS :has(:checked).
5
+ import { useId } from "react";
6
+ export function SegmentedControl({ label, options, value, onChange, }) {
7
+ // Radio buttons form one group by sharing a name.
8
+ const name = useId();
9
+ return (_jsxs("fieldset", { className: "flex rounded-full bg-muted p-1 text-sm", children: [_jsx("legend", { className: "sr-only", children: label }), options.map((option) => (_jsxs("label", { className: "flex min-h-11 cursor-pointer items-center rounded-full px-4 text-muted-foreground has-checked:bg-card has-checked:font-medium has-checked:text-foreground has-checked:shadow-sm has-focus-visible:outline-2 has-focus-visible:outline-ring has-focus-visible:outline-offset-2", children: [_jsx("input", { type: "radio", className: "sr-only", name: name, value: option.value, checked: option.value === value, onChange: () => onChange(option.value) }), option.label] }, option.value)))] }));
10
+ }
@@ -0,0 +1,6 @@
1
+ export { Button } from "./Button.js";
2
+ export { Card } from "./Card.js";
3
+ export { List } from "./List.js";
4
+ export { ListRow } from "./ListRow.js";
5
+ export { Meter } from "./Meter.js";
6
+ export { SegmentedControl } from "./SegmentedControl.js";
package/dist/index.js ADDED
@@ -0,0 +1,7 @@
1
+ // The package's components. The tokens they use come from theme.css.
2
+ export { Button } from "./Button.js";
3
+ export { Card } from "./Card.js";
4
+ export { List } from "./List.js";
5
+ export { ListRow } from "./ListRow.js";
6
+ export { Meter } from "./Meter.js";
7
+ export { SegmentedControl } from "./SegmentedControl.js";
@@ -0,0 +1 @@
1
+ export declare function joinClasses(...names: (string | undefined)[]): string;
@@ -0,0 +1,6 @@
1
+ // Joins class names, skipping any that aren't set. It doesn't resolve conflicts: a
2
+ // component's `className` prop is for layout (flex-1, w-full, a margin), which the
3
+ // component's own classes never set.
4
+ export function joinClasses(...names) {
5
+ return names.filter(Boolean).join(" ");
6
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kyzarky/ui",
3
- "version": "0.1.0",
4
- "description": "Design tokens for Ky Merritt's apps, as a Tailwind CSS 4 theme with light and dark values",
3
+ "version": "0.3.0",
4
+ "description": "Design tokens and React components for Ky Merritt's apps, built on Tailwind CSS 4",
5
5
  "license": "MIT",
6
6
  "author": "Ky Merritt",
7
7
  "repository": {
@@ -10,30 +10,42 @@
10
10
  },
11
11
  "type": "module",
12
12
  "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
13
17
  "./theme.css": "./theme.css"
14
18
  },
15
19
  "files": [
16
- "theme.css"
20
+ "theme.css",
21
+ "dist"
17
22
  ],
18
23
  "publishConfig": {
19
24
  "access": "public"
20
25
  },
21
26
  "scripts": {
27
+ "build": "tsc -p tsconfig.build.json",
28
+ "prepack": "npm run build",
22
29
  "typecheck": "tsc --noEmit",
23
30
  "lint": "biome check .",
24
31
  "format": "biome check --write .",
25
32
  "test": "vitest run",
26
- "verify": "npm run typecheck && npm run lint && npm run test",
27
- "test:browsers": "node browser-check.ts"
33
+ "test:browsers": "node browser-check.ts",
34
+ "verify": "npm run typecheck && npm run lint && npm run test && npm run build"
28
35
  },
29
36
  "peerDependencies": {
37
+ "react": "^19.0.0",
30
38
  "tailwindcss": "^4.0.0"
31
39
  },
32
40
  "devDependencies": {
33
41
  "@biomejs/biome": "^2.5.15",
34
42
  "@tailwindcss/node": "^4.3.3",
35
43
  "@types/node": "^26.6.4",
44
+ "@types/react": "^19.3.0",
45
+ "@types/react-dom": "^19.3.0",
36
46
  "playwright": "^1.64.0",
47
+ "react": "^19.3.0",
48
+ "react-dom": "^19.3.0",
37
49
  "tailwindcss": "^4.3.3",
38
50
  "typescript": "~6.0.3",
39
51
  "vitest": "^5.0.3"
package/theme.css CHANGED
@@ -9,6 +9,10 @@
9
9
  color-scheme itself. Components use the utilities (bg-card, text-muted-foreground) and
10
10
  never Tailwind's dark: variant. */
11
11
 
12
+ /* The components are published as compiled files full of Tailwind class names. This has
13
+ the app's Tailwind scan them, so it generates those classes along with the app's own. */
14
+ @source "./dist";
15
+
12
16
  /* In the base layer, so an app's own unlayered rules win wherever it puts them. That's
13
17
  how an app themes the package: by redefining a token. */
14
18
  @layer base {