@inkorange/space-ui 1.9.0 → 1.11.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/AGENTS.md ADDED
@@ -0,0 +1,112 @@
1
+ # AGENTS.md
2
+
3
+ Instructions for AI coding agents. Two audiences, in order: writing an
4
+ application that uses SpaceUI, then working inside this repository.
5
+
6
+ The complete, generated API reference lives at
7
+ <https://space-components.vercel.app/llms.txt> (and `llms-full.txt` for every
8
+ prop, data shape and token). It is derived from this source on every deploy, so
9
+ it describes the published version exactly. Prefer it over anything you
10
+ remember about this library.
11
+
12
+ ---
13
+
14
+ ## Using SpaceUI in an application
15
+
16
+ ```bash
17
+ npm install @inkorange/space-ui
18
+ ```
19
+
20
+ ```tsx
21
+ import "@inkorange/space-ui/styles.css";
22
+ import { Card, Heading, Text, Button } from "@inkorange/space-ui";
23
+ ```
24
+
25
+ Three entry points, and no others: the package itself, `./styles.css` (the one
26
+ required stylesheet) and `./tokens.css` (the token layer alone, for a project
27
+ that wants the palette and the scales without the components).
28
+
29
+ 1. **Import the stylesheet once, at the application root.** All component CSS
30
+ ships precompiled in one file. There is no per-component import, and no Sass
31
+ for consumers.
32
+ 2. **Do not add a provider, context or theme wrapper.** Components read tokens
33
+ straight from CSS custom properties on the document.
34
+ 3. **The library is dark-only, by design.** The tokens can be re-pointed, but
35
+ the components are drawn as lit glass over a dark ground, so a light
36
+ palette misrepresents every surface. There is no supported light theme; do
37
+ not build one.
38
+ 4. **Style with tokens, never raw lengths or colours.** `var(--spacing-md)`,
39
+ not `16px`. Spacing is an 8pt grid — every step is a multiple of
40
+ `--sp-grid-base-size`. Control heights come from `--sp-control-height`
41
+ (40px, with `-sm` 36 and `-lg` 48), so a Button and the Select beside it
42
+ match. Colour is a named role: `primary` for emphasis, `success`,
43
+ `warning`, `danger` and `accent` for status, with `-solid`, `-text`,
44
+ `-soft`, `-border`, `-glow` and `-deep` saying which job within the role.
45
+ 5. **Override a component token on a class, not with an inline style.** An
46
+ inline style wins over every media and container query the consumer will
47
+ later write.
48
+
49
+ ```css
50
+ .featured { --sp-carousel-view-count: 2.5; }
51
+ ```
52
+ 6. **Boolean props default to false**, so `<Button fullWidth />` is the idiom,
53
+ not `fullWidth={true}`. The one exception is `animated`, which defaults to
54
+ true.
55
+ 7. **Selection components are controlled through `onValueChange`** — Select,
56
+ Slider, RadioGroup, CheckboxGroup, Tabs. Native text inputs (TextField,
57
+ TextArea) keep React's own `value` and `onChange`.
58
+ 8. **Keyboard support and ARIA are already inside each component.** Roving
59
+ tabindex, arrow-key navigation, Escape handling, `aria-checked`,
60
+ `aria-expanded` and `aria-current` are implemented; do not re-declare them
61
+ from outside. Do pass `aria-label` where a control has no visible label.
62
+ 9. **Nothing touches the DOM at import time.** Components that measure do it in
63
+ effects, so the entry point imports cleanly in a server build. Components
64
+ needing the top layer use the platform popover API, so no portal target has
65
+ to be provided.
66
+
67
+ Peer dependencies are `react >= 19` and `react-dom >= 19`. There are no other
68
+ runtime dependencies.
69
+
70
+ ---
71
+
72
+ ## Working in this repository
73
+
74
+ A pnpm monorepo: the library in `packages/space-ui`, a Ladle gallery in
75
+ `apps/docs`.
76
+
77
+ ```
78
+ pnpm test # vitest: a happy-dom unit project and a real-Chromium browser project
79
+ pnpm build # build the library
80
+ pnpm docs # the gallery at localhost:61000
81
+ pnpm changeset # required for any change under packages/space-ui/src
82
+ ```
83
+
84
+ - **Styling a component follows a fixed pattern**, documented in
85
+ [CLAUDE.md](CLAUDE.md) and as a skill in
86
+ `.claude/skills/space-ui-styling/`. Read one of them before writing a
87
+ `*.module.scss`. The short version: spacing names a step, type is `rem()`
88
+ written in px, control heights come from the ladder, colour is a token, and
89
+ the only exceptions are hairlines and optical nudges — which go in the
90
+ `EXCEPTIONS` map in `src/components/grid.test.ts` with a reason.
91
+ - **Several invariants are tests, not conventions**, and they fail CI rather
92
+ than review: the spacing grid and rem type (`grid.test.ts`), the token
93
+ surface (`tokens.test.ts`), the ban on the component library this one
94
+ replaced, and the documentation a component publishes
95
+ (`published-docs.test.ts` — every component needs a description, and no doc
96
+ comment may name anything internal, because doc comments ship in the `.d.ts`
97
+ and in `llms.txt`).
98
+ - **A component's JSDoc is public copy.** It is published into the shipped
99
+ types, the gallery's prop tables, and the generated agent documentation.
100
+ Write it for someone who installed this from npm: what the component is,
101
+ when to reach for it, and what to use instead when it is the wrong choice.
102
+ - **Every PR touching `packages/space-ui/src` needs a changeset.** CI enforces
103
+ it. Prefer a minor bump over a major one.
104
+ - **Visual changes wait for the owner's sign-off in the gallery** before being
105
+ committed.
106
+ - **Commits are authored solely by Chris West.** Never add `Co-Authored-By` or
107
+ any AI-attribution trailer.
108
+ - Agent-facing files — `llms.txt`, `llms-full.txt`, `reference.html`,
109
+ `robots.txt`, `sitemap.xml` — are generated at docs build time by
110
+ `apps/docs/vite-plugin-agent-docs.ts` from the same TypeScript AST pass that
111
+ builds the gallery's prop tables. Never hand-write them, and never commit
112
+ them: they are build output, and a committed copy goes stale.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/@inkorange/space-ui)](https://www.npmjs.com/package/@inkorange/space-ui)
6
6
  ![coverage](https://img.shields.io/badge/coverage-93%25-brightgreen)
7
- ![minzip](https://img.shields.io/badge/minzip-40.6%20kB-blue)
7
+ ![minzip](https://img.shields.io/badge/minzip-41.3%20kB-blue)
8
8
  ![dependencies](https://img.shields.io/badge/dependencies-0-blue)
9
9
  [![docs](https://img.shields.io/badge/docs-live%20gallery-0b7dd6)](https://space-components.vercel.app)
10
10
 
@@ -205,6 +205,40 @@ their edge cases already found rather than imagined.
205
205
  **Requires** React 19 and a dark surface. There is deliberately no light theme;
206
206
  these components assume the dark they were drawn for.
207
207
 
208
+ ## For AI agents
209
+
210
+ The full API — every component, prop, data shape and design token — is
211
+ published in machine-readable form, generated from this source on every deploy
212
+ so it describes the published version exactly:
213
+
214
+ - [`llms.txt`](https://space-components.vercel.app/llms.txt) — the index:
215
+ install, the rules below, the scales, and one line per component.
216
+ - [`llms-full.txt`](https://space-components.vercel.app/llms-full.txt) — the
217
+ whole reference, as one file.
218
+ - [`reference.html`](https://space-components.vercel.app/reference.html) — the
219
+ same, as a page that needs no JavaScript.
220
+ - [`AGENTS.md`](https://github.com/inkorange/space-ui/blob/main/AGENTS.md) —
221
+ the short version, and it ships inside the package, so an agent working in a
222
+ project that depends on SpaceUI finds it without fetching anything.
223
+
224
+ The things that are easy to get wrong because no type signature says them:
225
+
226
+ - **One stylesheet import, at the root.** No per-component CSS, no provider, no
227
+ context, no theme wrapper, no build plugin.
228
+ - **Dark only.** The tokens can be re-pointed, but there is no supported light
229
+ theme — these components are drawn as lit glass over a dark ground.
230
+ - **Tokens, not lengths.** `var(--spacing-md)` over `16px`; spacing is an 8pt
231
+ grid and control heights come from a three-step ladder, so controls placed
232
+ side by side line up without arithmetic.
233
+ - **Set a component token on a class, never in an inline `style`** — inline
234
+ beats every media query a consumer will write afterwards.
235
+ - **Booleans default to false**, so `<Button fullWidth />` is the idiom.
236
+ `animated` is the one exception, defaulting to true.
237
+ - **Selection is controlled through `onValueChange`.** Native text inputs keep
238
+ React's `value` and `onChange`.
239
+ - **Keyboard behaviour and ARIA are already inside the components.** Add
240
+ `aria-label` where there is no visible label; do not re-declare the rest.
241
+
208
242
  ## License
209
243
 
210
244
  MIT
@@ -59,4 +59,15 @@ export interface AutocompleteProps extends Omit<React.InputHTMLAttributes<HTMLIn
59
59
  /** Applied to the wrapper — set width here. */
60
60
  style?: React.CSSProperties;
61
61
  }
62
+ /**
63
+ * A text field that offers matching rows as you type.
64
+ *
65
+ * It holds no data of its own. Give it `options` and it renders them,
66
+ * highlights one, moves that highlight with the arrow keys, and calls
67
+ * `onSelect` when a row is chosen — however those options were produced.
68
+ *
69
+ * The list is a popover rather than a block in the flow: results that appear
70
+ * mid-keystroke and push the page down under the cursor are disorienting, and
71
+ * whatever is being searched usually sits directly below the field.
72
+ */
62
73
  export declare function Autocomplete({ value, onValueChange, options, onSelect, icon, caseSensitive, preFiltered, loading, loadingMessage, emptyMessage, footer, animated, className, style, ...inputProps }: AutocompleteProps): React.JSX.Element;
@@ -24,9 +24,9 @@ export interface BadgeProps extends SpacingProps, Omit<React.HTMLAttributes<HTML
24
24
  variant?: "soft" | "solid";
25
25
  /** 1 is the default chip; 2 is for standalone use away from body text. */
26
26
  size?: "1" | "2";
27
- /** Badges are single-line by default (Radix parity). Long free-text
28
- * content — e.g. user-named star systems on planet cards — opts into
29
- * wrapping so it can't overflow its card. */
27
+ /** Badges are single-line by default: one that wraps stops reading as a
28
+ * badge. Opt into wrapping for free text you do not control — anything a
29
+ * reader named rather than you — so it cannot overflow what contains it. */
30
30
  wrap?: boolean;
31
31
  /** Label text. Keep it short — a badge that wraps stops reading as one. */
32
32
  children?: ReactNode;
@@ -13,8 +13,12 @@ export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElemen
13
13
  animated?: boolean;
14
14
  }
15
15
  /**
16
- * "Limb-lit glass" button: cosmic glass capsule whose rim glows like a planet's
17
- * sunlit atmosphere limb, with twinkling stars inside and a periodic glint
18
- * sweep. Plain <button>, so it composes with Radix Dialog.Trigger (Slot).
16
+ * The thing a reader presses to make something happen. A glass capsule whose
17
+ * rim glows like a planet's sunlit limb, with stars twinkling inside it and a
18
+ * glint that sweeps across it now and then.
19
+ *
20
+ * It renders a plain `<button>` and forwards its ref, so it drops into
21
+ * anything that needs to borrow a trigger — a dialog, a menu, a router link's
22
+ * render prop — without a wrapper in between.
19
23
  */
20
24
  export declare const Button: import("react").ForwardRefExoticComponent<ButtonProps & import("react").RefAttributes<HTMLButtonElement>>;
@@ -72,6 +72,22 @@ export declare function Title({ className, ...rest }: HeadingProps): React.JSX.E
72
72
  * description. Optional, and only announced when present.
73
73
  */
74
74
  export declare function Description(props: TextProps): React.JSX.Element;
75
+ /**
76
+ * A modal window over the page, for a task that has to finish or be abandoned
77
+ * before anything else happens.
78
+ *
79
+ * It rides the platform's own `<dialog>`, so the backdrop, the top layer, the
80
+ * Escape key and the focus trap are the browser's rather than an
81
+ * approximation of them. Opening moves focus inside; closing returns it to
82
+ * whatever opened it.
83
+ *
84
+ * For a decision with consequences — deleting, discarding, overwriting —
85
+ * reach for `AlertDialog`, which refuses to close by a click outside, so a
86
+ * misplaced click cannot answer the question for the reader.
87
+ *
88
+ * Compose it from the parts hanging off this name: `Dialog.Trigger`,
89
+ * `Dialog.Content`, `Dialog.Title`, `Dialog.Description` and `Dialog.Close`.
90
+ */
75
91
  export declare const Dialog: (({ open, onOpenChange, children }: DialogProps) => React.JSX.Element) & {
76
92
  Trigger: typeof Trigger;
77
93
  Close: typeof Close;
@@ -56,6 +56,22 @@ declare function Item({ asChild, color, onSelect, children }: DropdownMenuItemPr
56
56
  * routine ones, so the two are not adjacent to a moving cursor.
57
57
  */
58
58
  declare function Separator(): React.JSX.Element;
59
+ /**
60
+ * A button that opens a list of actions.
61
+ *
62
+ * Actions, not choices: every item does something when picked, and nothing in
63
+ * the menu holds a value. For choosing one of a set of values, that is
64
+ * `Select`, which looks similar and means something entirely different to a
65
+ * screen reader.
66
+ *
67
+ * Arrow keys move through the items, Home and End jump to the ends, Escape
68
+ * closes and returns focus to the button, and typing a letter jumps to the
69
+ * next item starting with it. The panel uses the browser's popover layer, so
70
+ * it stacks above the page without a z-index to maintain.
71
+ *
72
+ * Compose it from `DropdownMenu.Item`, `DropdownMenu.Label` and
73
+ * `DropdownMenu.Separator`.
74
+ */
59
75
  export declare const DropdownMenu: typeof DropdownMenuRoot & {
60
76
  Item: typeof Item;
61
77
  Separator: typeof Separator;
@@ -6,13 +6,15 @@ export interface IconToggleOption<V extends string> {
6
6
  label: string;
7
7
  }
8
8
  /**
9
- * A segmented icon toggle: every option is always visible in one pill, the
10
- * selected one reads as active, clicking any option selects it. Built for the
11
- * scene view switcher (planet / orrery / surface), where the old pattern —
12
- * showing only the views you are NOT on and swapping icons on click — made
13
- * the control's meaning flip under the cursor (owner: both icons should be
14
- * "always visible, the selected one is the active one"). Generic over the
15
- * option set so other icon-mode switchers can reuse it.
9
+ * A segmented icon toggle: every option visible at once in a single pill, the
10
+ * selected one reading as active, and a click on any of them selecting it.
11
+ *
12
+ * Reach for it to switch between modes or views of the same thing, where the
13
+ * options are few and each has an obvious glyph. The alternative pattern — one
14
+ * button that shows the mode you are NOT in and swaps its own icon on click —
15
+ * is deliberately not what this does: the control's meaning changes under the
16
+ * cursor, and nobody can tell whether the icon means "you are here" or "go
17
+ * here". Generic over the option set, so the values stay your own union type.
16
18
  */
17
19
  export declare function IconToggle<V extends string>({ options, value, onValueChange, orientation, className, }: {
18
20
  /** The segments, in display order. Every option is always visible —
@@ -7,9 +7,13 @@ export interface LoaderProps {
7
7
  className?: string;
8
8
  }
9
9
  /**
10
- * Orbital spinner: a small pulsing "planet" disc at the center of a static
11
- * ring, with a "moon" dot traveling the ring. Dependency-free CSS animation
12
- * so it composes cleanly wherever a lazy chunk or async panel needs a
13
- * space-themed loading state (see SurfaceView's dynamic-import loader).
10
+ * An orbital spinner, for work whose length you cannot measure: a small
11
+ * pulsing planet at the centre of a static ring, with a moon travelling it.
12
+ * Where you CAN measure the work, use `Progress` instead — a spinner that
13
+ * could have been a bar tells the reader less than it knows.
14
+ *
15
+ * The motion is pure CSS and runs on the compositor, so it keeps turning
16
+ * smoothly while the main thread is busy doing the thing it is waiting on —
17
+ * which is exactly when a loader is on screen.
14
18
  */
15
19
  export declare function Loader({ size, label, className }: LoaderProps): import("react").JSX.Element;
@@ -34,6 +34,21 @@ export interface RadioGroupItemProps {
34
34
  * nesting one label inside another is invalid HTML.
35
35
  */
36
36
  declare function Item({ value, disabled, children }: RadioGroupItemProps): React.JSX.Element;
37
+ /**
38
+ * Exactly one choice from a set you can see all of at once.
39
+ *
40
+ * Use it where the options matter enough to be read side by side and there are
41
+ * few enough to show — up to about seven. Past that a `Select` keeps the page
42
+ * calm. Where several answers can be true at the same time, that is
43
+ * `CheckboxGroup`.
44
+ *
45
+ * Hold the chosen value and pass `onValueChange`. Each orb ignites when it is
46
+ * chosen, arrow keys move between the options the way a native radio group
47
+ * does, and a label that wraps to two lines keeps its orb beside the first
48
+ * line rather than drifting to the middle.
49
+ *
50
+ * Compose it from `RadioGroup.Item`.
51
+ */
37
52
  export declare const RadioGroup: typeof RadioGroupRoot & {
38
53
  Item: typeof Item;
39
54
  };
@@ -47,6 +47,23 @@ export interface SelectItemProps {
47
47
  * selected, so anything rendered here has to read correctly in both places.
48
48
  */
49
49
  declare function Item({ value, disabled, children }: SelectItemProps): React.JSX.Element;
50
+ /**
51
+ * Choosing one value from a list, with the list kept out of the way until it
52
+ * is wanted.
53
+ *
54
+ * The right control when the options are many, or dull, or both — a country, a
55
+ * size, a sort order. Where there are few options and each one deserves to be
56
+ * read, `RadioGroup` shows them all without a click. Where the items are
57
+ * actions rather than values, that is `DropdownMenu`.
58
+ *
59
+ * The panel opens on the browser's popover layer, which is what lets it escape
60
+ * a dialog, a transformed ancestor or a clipping overflow and sit above the
61
+ * page at its full height. It flips above the trigger when there is no room
62
+ * below, follows the trigger if the page scrolls, and closes on Escape with
63
+ * focus handed back. Typing jumps to a matching option.
64
+ *
65
+ * Compose it from `Select.Item`.
66
+ */
50
67
  export declare const Select: typeof SelectRoot & {
51
68
  Item: typeof Item;
52
69
  };
@@ -1,28 +1,50 @@
1
1
  import type * as React from "react";
2
2
  export interface SliderProps {
3
- /** Current value, as a single-element array. Fully controlled. */
3
+ /** The value, as an array. One number is one thumb; two is a range, read
4
+ * as `[start, end]` and kept in that order — the thumbs cannot swap
5
+ * places, so index 0 is always the lower of the two. Fully controlled. */
4
6
  value: number[];
5
- /** Called with the new value array as the thumb moves. */
6
- onValueChange: (v: number[]) => void;
7
+ /** Called with the whole array as a thumb moves, in the same shape it was
8
+ * given. */
9
+ onValueChange: (value: number[]) => void;
7
10
  /** Lower bound. */
8
11
  min: number;
9
12
  /** Upper bound. */
10
13
  max: number;
11
- /** Smallest increment the thumb can move by. */
12
- step: number;
14
+ /** The increment a thumb moves by, and the grid every value lands on: with
15
+ * `step={10}` over 0–100 a thumb stops at 0, 10, 20 … and nothing between,
16
+ * whether it is dragged, pressed on the track, or walked with the arrow
17
+ * keys. Counted from `min`, so a range of 5–105 with a step of 10 lands on
18
+ * 5, 15, 25 … Default 1, the same as a bare `<input type="range">`. */
19
+ step?: number;
20
+ /** How close the two thumbs may come, in the value's own units. 0 lets
21
+ * them meet; a positive number keeps a gap, for a range that would mean
22
+ * nothing empty — "between 2 and 2 bedrooms". Ignored with one thumb. */
23
+ minDistance?: number;
13
24
  /** Blocks interaction and removes it from the tab order. */
14
25
  disabled?: boolean;
15
26
  /** Merged onto the wrapper. */
16
27
  className?: string;
17
- /** Accessible name. Required unless a visible label references it,
18
- * since a slider has no text of its own.
19
- */
28
+ /** Accessible name. Required unless a visible label references it, since a
29
+ * slider has no text of its own. With two thumbs each one needs its own
30
+ * name, so this is suffixed — "Mass minimum", "Mass maximum" — unless
31
+ * `thumbLabels` says otherwise. */
20
32
  "aria-label"?: string;
33
+ /** Names for the two thumbs, when the generated ones do not fit: a price
34
+ * range might want "From" and "To". */
35
+ thumbLabels?: [string, string];
21
36
  }
22
37
  /**
23
- * A range control for a continuous value.
38
+ * A range control: one thumb for a value, two for a span between values.
24
39
  *
25
- * Takes and returns an array so multiple thumbs can be added later without a
26
- * breaking change, even though it renders one today.
40
+ * Each thumb is a real `<input type="range">`, so arrow keys, Home, End and
41
+ * Page Up/Down all work without this component reimplementing them, and a
42
+ * screen reader announces a slider because it is one. What is drawn — the
43
+ * glass tube, the lit fill, the little planet of a thumb — sits on top of
44
+ * inputs that are invisible but still doing the work.
45
+ *
46
+ * With two thumbs they cannot cross: each is clamped to the other, so the
47
+ * array stays in order and a range can never read back inverted. `minDistance`
48
+ * keeps a gap between them where a zero-width range would be meaningless.
27
49
  */
28
50
  export declare const Slider: React.ForwardRefExoticComponent<SliderProps & React.RefAttributes<HTMLInputElement>>;
@@ -60,6 +60,21 @@ declare function Content({ value, children, }: {
60
60
  /** Panel contents. Unmounted while another tab is selected. */
61
61
  children?: ReactNode;
62
62
  }): React.JSX.Element | null;
63
+ /**
64
+ * Several panels sharing one piece of the page, one visible at a time.
65
+ *
66
+ * Right for views of the same subject that a reader moves between and
67
+ * compares — overview, history, settings for one record. Wrong for steps in a
68
+ * sequence, where the reader needs to know how far along they are, and wrong
69
+ * for content that should be findable by search or linkable on its own: a
70
+ * hidden panel is invisible to both.
71
+ *
72
+ * Arrow keys move between the tabs, Home and End jump to the ends, and only
73
+ * the selected tab is in the tab order, so one press of Tab leaves the strip
74
+ * for the panel instead of walking every tab in it.
75
+ *
76
+ * Compose it from `Tabs.List`, `Tabs.Trigger` and `Tabs.Content`.
77
+ */
63
78
  export declare const Tabs: typeof TabsRoot & {
64
79
  List: typeof List;
65
80
  Trigger: typeof Trigger;
@@ -20,6 +20,18 @@ declare function Slot({ children, }: {
20
20
  /** The slot's contents — an icon, a unit, a short affordance. */
21
21
  children?: ReactNode;
22
22
  }): React.JSX.Element;
23
+ /**
24
+ * A single line of text the reader types.
25
+ *
26
+ * A real `<input>` underneath, so `value`, `onChange`, `type`, `required`,
27
+ * `autoComplete` and the rest of the native attributes all work as they
28
+ * already do, and the browser's own autofill and validation come along with
29
+ * them. For more than a line, use `TextArea`.
30
+ *
31
+ * Put an icon, a unit or a small button beside the text with `TextField.Slot`.
32
+ * It sits inside the field's rim and keeps its own padding, so the text never
33
+ * runs underneath it.
34
+ */
23
35
  export declare const TextField: React.ForwardRefExoticComponent<TextFieldProps & React.RefAttributes<HTMLInputElement>> & {
24
36
  Slot: typeof Slot;
25
37
  };