@atelic-action/ui 0.8.0 → 0.9.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
@@ -41,7 +41,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
41
41
  | Import | Exports |
42
42
  |---|---|
43
43
  | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
44
- | `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
44
+ | `@atelic-action/ui/components` | `NotFound` and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
45
45
  | `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
46
46
  | `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
47
47
  | `@atelic-action/ui/tokens` | `Palette`, `atelicPalette`, `Fonts`, `atelicFonts`, `toThemeCSS`, `themeTokenMap` |
@@ -50,6 +50,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
50
50
  | `@atelic-action/ui/styles/base.css` | Resets, the `.mkt` canvas, typography, and layout helpers |
51
51
  | `@atelic-action/ui/styles/chrome.css` | Styles for everything under `chrome` |
52
52
  | `@atelic-action/ui/styles/components.css` | Layout defaults for everything under `components` |
53
+ | `@atelic-action/ui/styles/star-rating.css` | `StarRating`'s styles alone, for an app that wears none of the site chrome; `components.css` already imports it |
53
54
 
54
55
  Every component renders from props alone. None reads a config file or a router, so a site maps its own config onto the props in its shell:
55
56
 
@@ -261,9 +262,23 @@ The blocks a page composes, each styled in `styles/components.css` inside the `a
261
262
  | `StackedBar` | One bar split into the parts of a whole, with a key | A part of zero is keyed and not drawn |
262
263
  | `ColumnChart` | A bar a period, the periods in groups that carry a label and a total | Months in quarters is the case it was cut for: a quarter reads against the one before it and the same one a year earlier |
263
264
  | `ThresholdScale` | Zones on a scale and a pin for each reading | For a number judged against lines, like a load time against good and poor |
265
+ | `StarRating` | A row of stars showing a rating in half star steps, and taking one when given `onChange` | Each star is two hit targets; picking the current rating clears it. Editable, it is one slider to a keyboard and a screen reader; read only, a picture named by the rating. `fillColor` and `outlineColor` take any CSS color, `size` and `max` set the star and the scale |
266
+
267
+ **StarRating outside a site.** It is the one component that does not sit under `.mkt` or read a theme token: its styles are in `styles/star-rating.css`, and its two colors are the `--star-fill` and `--star-outline` custom properties, falling back to the text color and a faint tint of it. An app that installs the package for this component alone imports that one sheet, not `components.css`.
264
268
 
265
269
  **Tones.** `good`, `warn`, `bad`, `info`, and `muted` are the one palette the package carries, because good, a warning, and broken read green, amber, and red whatever the brand is. A component sets `data-tone`, and `--tone`, `--tone-fill`, and `--tone-bg` follow for whatever sits inside. A site's theme may restate `--tone-good` and the rest on `.mkt`; `info` and `muted` come from the theme's own primary and neutral tokens.
266
270
 
271
+ ## The Gallery
272
+
273
+ `gallery/` is one static page, The Components, that shows every page component on its own with made up data for a fictional bakery, wearing the practice's own palette and type. It lives at [ui.atelic.me](https://ui.atelic.me), noindexed and disallowed in its `robots.txt`, since it is for the people building with the package and never search inventory. It imports the components and stylesheets from `src/` by relative path rather than from npm, so it always shows the code in the repo, and it reads the package name and version from `package.json` at build time. It sits outside `src/`, so it is never published.
274
+
275
+ ```bash
276
+ bun run gallery:dev # a dev server on the repo's own source
277
+ bun run gallery:build # the static page, written to gallery/dist
278
+ ```
279
+
280
+ `vercel.json` builds it the same way and serves `gallery/dist`. Typecheck and lint cover it, and CI builds it, so a component change that breaks the gallery fails CI. **A new page component gets a gallery section in the same change**, with each of its meaningfully different forms shown.
281
+
267
282
  ## Releasing
268
283
 
269
284
  1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -28,13 +28,16 @@
28
28
  "./styles/base.css": "./src/styles/base.css",
29
29
  "./styles/chrome.css": "./src/styles/chrome.css",
30
30
  "./styles/components.css": "./src/styles/components.css",
31
+ "./styles/star-rating.css": "./src/styles/star-rating.css",
31
32
  "./package.json": "./package.json"
32
33
  },
33
34
  "scripts": {
34
35
  "lint": "biome check .",
35
36
  "lint:fix": "biome check . --write",
36
- "typecheck": "tsc --noEmit",
37
- "test:run": "vitest run"
37
+ "typecheck": "tsc --noEmit && tsc --noEmit -p gallery",
38
+ "test:run": "vitest run",
39
+ "gallery:dev": "vite gallery",
40
+ "gallery:build": "vite build gallery"
38
41
  },
39
42
  "peerDependencies": {
40
43
  "lucide-react": "^1.23.0",
@@ -47,11 +50,13 @@
47
50
  "@testing-library/react": "^16.0.1",
48
51
  "@types/react": "^19.0.0",
49
52
  "@types/react-dom": "^19.0.0",
53
+ "@vitejs/plugin-react": "^6.1.1",
50
54
  "jsdom": "^25.0.1",
51
55
  "lucide-react": "^1.23.0",
52
56
  "react": "^19.0.0",
53
57
  "react-dom": "^19.0.0",
54
58
  "typescript": "^5.6.3",
59
+ "vite": "^8.3.2",
55
60
  "vitest": "^4.0.0"
56
61
  },
57
62
  "trustedDependencies": [
@@ -0,0 +1,153 @@
1
+ import { Star } from "lucide-react";
2
+ import { type CSSProperties, type KeyboardEvent, useState } from "react";
3
+
4
+ const STEP = 0.5;
5
+
6
+ export interface StarRatingProps {
7
+ /** The rating, in half star steps from 0.5 up to `max`; null is unrated. */
8
+ value: number | null;
9
+ /** Called with the new rating, or null to clear it. Leave it out for a read only row. */
10
+ onChange?: (value: number | null) => void;
11
+ /** How many stars the scale runs to. */
12
+ max?: number;
13
+ /** The side of one star, in pixels. */
14
+ size?: number;
15
+ /** The color of a filled star: any CSS color. Defaults to the text color around it. */
16
+ fillColor?: string;
17
+ /** The color of a star's outline: any CSS color. Defaults to a faint tint of the text color. */
18
+ outlineColor?: string;
19
+ /** What a screen reader calls the row. */
20
+ label?: string;
21
+ className?: string;
22
+ }
23
+
24
+ /** "4.5 stars", "1 star", "Not rated": the rating as a person would say it. */
25
+ export function describeStarRating(value: number | null): string {
26
+ if (value === null) return "Not rated";
27
+ return `${value} ${value === 1 ? "star" : "stars"}`;
28
+ }
29
+
30
+ /**
31
+ * A row of stars that shows a rating in half star steps and, given `onChange`,
32
+ * takes one. Each star is two hit targets, its left half and its right, so a
33
+ * pointer lands on 3.5 or 4 without a second control. Picking the rating
34
+ * already set clears it, which is the only way back to unrated and needs no
35
+ * button of its own.
36
+ *
37
+ * To a keyboard and a screen reader an editable row is one slider: the arrows
38
+ * move half a star, Home clears, End is the top of the scale. The halves stay
39
+ * out of the tab order, so the row is one stop rather than ten. Without
40
+ * `onChange` it is a picture with the rating as its name.
41
+ *
42
+ * The two colors are the `--star-fill` and `--star-outline` custom properties.
43
+ * `fillColor` and `outlineColor` set them on this row; a site may also set
44
+ * them in its own CSS for every row at once.
45
+ */
46
+ export function StarRating({
47
+ value,
48
+ onChange,
49
+ max = 5,
50
+ size = 24,
51
+ fillColor,
52
+ outlineColor,
53
+ label = "Rating",
54
+ className,
55
+ }: StarRatingProps) {
56
+ const [hover, setHover] = useState<number | null>(null);
57
+ const shown = hover ?? value ?? 0;
58
+
59
+ const style = {
60
+ "--star-size": `${size}px`,
61
+ ...(fillColor ? { "--star-fill": fillColor } : {}),
62
+ ...(outlineColor ? { "--star-outline": outlineColor } : {}),
63
+ } as CSSProperties;
64
+ const classes = ["star-rating", className].filter(Boolean).join(" ");
65
+
66
+ const stars = Array.from({ length: max }, (_, i) => i + 1).map((star) => {
67
+ const fill = shown >= star ? "100%" : shown >= star - STEP ? "50%" : "0%";
68
+ return (
69
+ <span key={star} className="star-rating-star">
70
+ <Star aria-hidden="true" size={size} className="star-rating-outline" />
71
+ <span aria-hidden="true" className="star-rating-fill" style={{ width: fill }}>
72
+ <Star size={size} />
73
+ </span>
74
+ {onChange &&
75
+ [star - STEP, star].map((rating, half) => (
76
+ <button
77
+ key={rating}
78
+ type="button"
79
+ tabIndex={-1}
80
+ aria-label={describeStarRating(rating)}
81
+ className={`star-rating-hit ${half === 0 ? "is-left" : "is-right"}`}
82
+ onMouseEnter={() => setHover(rating)}
83
+ onClick={() => {
84
+ // Drop the preview with the pick: the pointer is still on this
85
+ // half, and a preview left up would hide a clear behind the
86
+ // very stars it just emptied.
87
+ setHover(null);
88
+ onChange(rating === value ? null : rating);
89
+ }}
90
+ />
91
+ ))}
92
+ </span>
93
+ );
94
+ });
95
+
96
+ if (!onChange) {
97
+ return (
98
+ <span
99
+ role="img"
100
+ aria-label={`${label}: ${describeStarRating(value)}`}
101
+ className={classes}
102
+ style={style}
103
+ >
104
+ {stars}
105
+ </span>
106
+ );
107
+ }
108
+
109
+ const set = (next: number | null) => {
110
+ if (next !== value) onChange(next);
111
+ };
112
+
113
+ const onKeyDown = (event: KeyboardEvent) => {
114
+ const current = value ?? 0;
115
+ switch (event.key) {
116
+ case "ArrowRight":
117
+ case "ArrowUp":
118
+ set(Math.min(max, current + STEP));
119
+ break;
120
+ case "ArrowLeft":
121
+ case "ArrowDown":
122
+ set(current - STEP > 0 ? current - STEP : null);
123
+ break;
124
+ case "Home":
125
+ set(null);
126
+ break;
127
+ case "End":
128
+ set(max);
129
+ break;
130
+ default:
131
+ return;
132
+ }
133
+ event.preventDefault();
134
+ };
135
+
136
+ return (
137
+ <span
138
+ role="slider"
139
+ tabIndex={0}
140
+ aria-label={label}
141
+ aria-valuemin={0}
142
+ aria-valuemax={max}
143
+ aria-valuenow={value ?? 0}
144
+ aria-valuetext={describeStarRating(value)}
145
+ className={`${classes} is-editable`}
146
+ style={style}
147
+ onKeyDown={onKeyDown}
148
+ onMouseLeave={() => setHover(null)}
149
+ >
150
+ {stars}
151
+ </span>
152
+ );
153
+ }
@@ -18,6 +18,7 @@ export {
18
18
  } from "./DataTable";
19
19
  export { NotFound, type NotFoundProps } from "./NotFound";
20
20
  export { StackedBar, type StackedBarProps, type StackedBarSegment } from "./StackedBar";
21
+ export { describeStarRating, StarRating, type StarRatingProps } from "./StarRating";
21
22
  export {
22
23
  type StatDelta,
23
24
  type StatDirection,
@@ -1,3 +1,5 @@
1
+ @import "./star-rating.css";
2
+
1
3
  /* Layout defaults for the components under src/components. A site's own
2
4
  classes (.page-hero, .eyebrow, .lead, .btn) arrive unlayered and win, so
3
5
  these only fill in where a site does not style them. */
@@ -0,0 +1,67 @@
1
+ /* StarRating: a row of stars in half star steps. On its own sheet, and not
2
+ under .mkt, so an app that wears none of the site chrome can import this
3
+ file alone; components.css imports it for every site.
4
+
5
+ The two colors are custom properties with fallbacks off the text color, so
6
+ the row needs no theme token and takes a color from a prop, from a site's
7
+ own rule, or from whatever it sits in. */
8
+ @layer atelic-ui {
9
+ .star-rating {
10
+ display: inline-flex;
11
+ align-items: center;
12
+ vertical-align: middle;
13
+ line-height: 0;
14
+ border-radius: 4px;
15
+ }
16
+ .star-rating.is-editable:focus-visible {
17
+ outline: 2px solid var(--star-focus, currentColor);
18
+ outline-offset: 2px;
19
+ }
20
+ .star-rating-star {
21
+ position: relative;
22
+ display: inline-block;
23
+ width: var(--star-size, 24px);
24
+ height: var(--star-size, 24px);
25
+ }
26
+ /* Pinned to the star's size: a base reset that caps an svg at its
27
+ container's width would shrink the filled star inside its clip to a
28
+ small whole star, where it has to stay full size and be cut in half. */
29
+ .star-rating .star-rating-star svg {
30
+ display: block;
31
+ width: var(--star-size, 24px);
32
+ height: var(--star-size, 24px);
33
+ max-width: none;
34
+ }
35
+ .star-rating-outline {
36
+ color: var(--star-outline, color-mix(in srgb, currentColor 40%, transparent));
37
+ }
38
+ /* The filled star sits over the outline and is clipped to none, half, or
39
+ all of its width. */
40
+ .star-rating-fill {
41
+ position: absolute;
42
+ inset-block: 0;
43
+ left: 0;
44
+ overflow: hidden;
45
+ pointer-events: none;
46
+ color: var(--star-fill, currentColor);
47
+ }
48
+ .star-rating-fill svg {
49
+ fill: currentColor;
50
+ }
51
+ .star-rating-hit {
52
+ position: absolute;
53
+ inset-block: 0;
54
+ width: 50%;
55
+ margin: 0;
56
+ padding: 0;
57
+ border: 0;
58
+ background: none;
59
+ cursor: pointer;
60
+ }
61
+ .star-rating-hit.is-left {
62
+ left: 0;
63
+ }
64
+ .star-rating-hit.is-right {
65
+ right: 0;
66
+ }
67
+ }