diff --git a/.changeset/color-picker-v1.md b/.changeset/color-picker-v1.md
new file mode 100644
index 0000000..f9ef20b
--- /dev/null
+++ b/.changeset/color-picker-v1.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': minor
+---
+
+Add the ColorPicker primitive with `Root`, `Label`, `Area`, `AreaThumb`, `Slider`, `SliderThumb`, `HexField`, `ChannelField`, `SwatchList`, `Swatch`, `Preview` and `EyeDropper`. The color is held as hue, saturation and brightness. The square keeps its shape at each hue, and a color that goes to black keeps the hue it had. The value is text: a hex color, `rgb()`, `hsl()` and `hsb()` come in, and `format` decides what goes out. `alpha` adds the fourth number and the `alpha` channel. `ColorPicker.AreaThumb` holds one native slider for each axis, each with the name of its channel and with `aria-valuetext`. The arrows move both axes from either of them. `ColorPicker.SwatchList` is a listbox of options. The parts paint nothing: `--color-picker-value`, `--color-picker-hue-color`, `--color-picker-area-x`, `--color-picker-slider-start` and `--color-picker-swatch-color` give your CSS what it needs.
diff --git a/.changeset/pin-input-v1.md b/.changeset/pin-input-v1.md
new file mode 100644
index 0000000..1921e72
--- /dev/null
+++ b/.changeset/pin-input-v1.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': minor
+---
+
+Add the PinInput primitive with `Root`, `Label` and `Cell`. Each cell is a real input with its own name, its `inputmode` and its `autocomplete`. A telephone shows the correct keyboard, and a code from a message fills the cells with one touch. A paste goes across the cells from the cell it starts in, and the characters that `type` or `pattern` refuses are dropped. The value has no holes. The characters fill the cells from the first one, and a press on a cell past the first empty one goes to that one. A character that goes away takes the ones after it one cell to the left. `onComplete` runs when the last cell takes a character, `mask` hides the characters, and `name` sends the whole value in a form.
diff --git a/.changeset/rating-v1.md b/.changeset/rating-v1.md
new file mode 100644
index 0000000..12c62fc
--- /dev/null
+++ b/.changeset/rating-v1.md
@@ -0,0 +1,5 @@
+---
+'@human-kit/ui': minor
+---
+
+Add the Rating primitive with `Root`, `Label`, `Output` and `Item`. With whole items the root is a radio group, and each item is a radio with its own name, because each value is one item. With `precision={0.5}` the root is a slider with `aria-valuenow` and `aria-valuetext`, because a radio group cannot say 3.5. The keyboard is the same in the two shapes. The arrows step by the precision, `Home` and `End` go to the ends, and `Delete` clears the value. The value follows the pointer before a press, in `Rating.Output` and in `--rating-display-value`. Each item carries `--rating-item-fill`, from 0 to 1, thus one shape over the item gives you a half star. `name` sends the value in a form, and a `
diff --git a/packages/ui/src/lib/color-picker/types.ts b/packages/ui/src/lib/color-picker/types.ts
new file mode 100644
index 0000000..3c78c5d
--- /dev/null
+++ b/packages/ui/src/lib/color-picker/types.ts
@@ -0,0 +1,230 @@
+import type { Snippet } from 'svelte';
+import type { HTMLAttributes, HTMLButtonAttributes, HTMLInputAttributes } from 'svelte/elements';
+import type {
+ Color,
+ ColorChannel,
+ ColorFormat,
+ ColorPickerChangeDetails,
+ ColorPickerContext,
+ ColorPickerSliderOrientation
+} from './root/context';
+
+export type {
+ Color,
+ ColorChannel,
+ ColorFormat,
+ ColorPickerChangeDetails,
+ ColorPickerContext,
+ ColorPickerSliderOrientation
+};
+
+export type ColorPickerRootProps = {
+ /** A stable id, from which the component makes its internal ARIA ids. Give one on a server. */
+ id?: string;
+ /**
+ * The color, as text: a hex color, `rgb()`, `hsl()` or `hsb()`. You can bind it with
+ * `bind:value`. The component answers in the format of `format`.
+ */
+ value?: string;
+ /** The color at the start, for when you give no `value`. The default is `#000000`. */
+ defaultValue?: string;
+ /**
+ * Give your own code full control of the color. The component stops to write back to `value`,
+ * and it reports only through `onChange`. Thus the parent can refuse a change.
+ */
+ controlledValue?: boolean;
+ /** The component calls it on each change, also on each move of a drag. */
+ onChange?: (value: string, details: ColorPickerChangeDetails) => void;
+ /**
+ * The component calls it when a sequence of changes ends: at the release of a key, and at the
+ * end of a drag. Use it for work that must not run on each move.
+ */
+ onChangeEnd?: (value: string, details: ColorPickerChangeDetails) => void;
+ /** The text format of the value. The default is `hex`. */
+ format?: ColorFormat;
+ /**
+ * Keeps an alpha in the color. The value then holds a fourth number, and `ColorPicker.Slider`
+ * accepts the `alpha` channel. The default is off.
+ */
+ alpha?: boolean;
+ /** Disables each control of the picker. The controls leave the tab order. */
+ disabled?: boolean;
+ /** Keeps the controls in the tab order, but the color does not change. */
+ readonly?: boolean;
+ /** Marks the color as invalid. Each control gets `aria-invalid`. */
+ invalid?: boolean;
+ /** The name of the hidden input that carries the color in a form. */
+ name?: string;
+ /** The id of the form that the hidden input belongs to, when the picker is outside it. */
+ form?: string;
+ /** The accessible name of the picker, for when there is no `ColorPicker.Label`. */
+ 'aria-label'?: string;
+ /** The id of the element that gives the picker its name, in place of `ColorPicker.Label`. */
+ 'aria-labelledby'?: string;
+ /** The id of the element that describes the picker, for example an error message. */
+ 'aria-describedby'?: string;
+ /** The content: the label, the area, the sliders and the fields. */
+ children?: Snippet;
+ /** The CSS class names of the root element. */
+ class?: string;
+ /** A bindable reference to the root element. */
+ element?: HTMLDivElement | null;
+ /** A bindable reference to the context, for a composition of your own. */
+ context?: ColorPickerContext;
+} & Omit<
+ HTMLAttributes,
+ | 'class'
+ | 'children'
+ | 'id'
+ | 'role'
+ | 'aria-label'
+ | 'aria-labelledby'
+ | 'aria-describedby'
+ | 'onchange'
+>;
+
+export type ColorPickerLabelProps = Omit, 'class'> & {
+ /** The CSS class names of the label. */
+ class?: string;
+};
+
+export type ColorPickerAreaProps = Omit, 'class'> & {
+ /** The channel of the horizontal axis. The default is `saturation`. */
+ xChannel?: ColorChannel;
+ /** The channel of the vertical axis, which counts from the bottom. The default is `brightness`. */
+ yChannel?: ColorChannel;
+ /** The content: a `ColorPicker.AreaThumb`. */
+ children?: Snippet;
+ /** The CSS class names of the area. */
+ class?: string;
+ /** A bindable reference to the area element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerAreaThumbRenderState = {
+ /** The position of the thumb on the horizontal axis, from 0 to 100. */
+ xPercent: number;
+ /** The position of the thumb on the vertical axis, from 0 to 100, from the bottom. */
+ yPercent: number;
+ /** Whether a pointer moves the thumb. */
+ dragging: boolean;
+ /** Whether one of the two inputs of the thumb has the focus. */
+ focused: boolean;
+};
+
+export type ColorPickerAreaThumbProps = Omit<
+ HTMLAttributes,
+ 'class' | 'children'
+> & {
+ /**
+ * The content. As a snippet with one argument, it receives the render state: `xPercent`,
+ * `yPercent`, `dragging` and `focused`.
+ */
+ children?: Snippet<[ColorPickerAreaThumbRenderState]> | Snippet;
+ /** The CSS class names of the thumb. */
+ class?: string;
+ /** A bindable reference to the thumb element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerSliderProps = Omit, 'class'> & {
+ /** The channel the slider moves, for example `hue` or `alpha`. */
+ channel: ColorChannel;
+ /** The direction of the track. The default is horizontal. */
+ orientation?: ColorPickerSliderOrientation;
+ /** The content: a `ColorPicker.SliderThumb`. */
+ children?: Snippet;
+ /** The CSS class names of the track. */
+ class?: string;
+ /** A bindable reference to the track element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerSliderThumbRenderState = {
+ /** The position of the thumb on the track, from 0 to 100. */
+ percent: number;
+ /** The value of the channel. */
+ value: number;
+ /** Whether a pointer moves the thumb. */
+ dragging: boolean;
+ /** Whether the input of the thumb has the focus. */
+ focused: boolean;
+};
+
+export type ColorPickerSliderThumbProps = Omit<
+ HTMLAttributes,
+ 'class' | 'children'
+> & {
+ /**
+ * The content. As a snippet with one argument, it receives the render state: `percent`,
+ * `value`, `dragging` and `focused`.
+ */
+ children?: Snippet<[ColorPickerSliderThumbRenderState]> | Snippet;
+ /** The CSS class names of the thumb. */
+ class?: string;
+ /** A bindable reference to the thumb element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerHexFieldProps = Omit<
+ HTMLInputAttributes,
+ 'class' | 'children' | 'value' | 'type'
+> & {
+ /** The CSS class names of the field. */
+ class?: string;
+ /** A bindable reference to the input element. */
+ element?: HTMLInputElement | null;
+};
+
+export type ColorPickerChannelFieldProps = Omit<
+ HTMLInputAttributes,
+ 'class' | 'children' | 'value' | 'type' | 'min' | 'max' | 'step'
+> & {
+ /** The channel the field holds, for example `red`. */
+ channel: ColorChannel;
+ /** The CSS class names of the field. */
+ class?: string;
+ /** A bindable reference to the input element. */
+ element?: HTMLInputElement | null;
+};
+
+export type ColorPickerSwatchListProps = Omit, 'class'> & {
+ /** The content: one `ColorPicker.Swatch` for each color. */
+ children?: Snippet;
+ /** The CSS class names of the list. */
+ class?: string;
+ /** A bindable reference to the list element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerSwatchProps = Omit<
+ HTMLAttributes,
+ 'class' | 'children' | 'aria-label'
+> & {
+ /** The color of the swatch, as text. */
+ color: string;
+ /** The name of the swatch, for the screen reader. The default is the text of its color. */
+ 'aria-label'?: string;
+ /** The content. Without it, the swatch is an empty element that your CSS paints. */
+ children?: Snippet;
+ /** The CSS class names of the swatch. */
+ class?: string;
+ /** A bindable reference to the swatch element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerPreviewProps = Omit, 'class'> & {
+ /** The content. Without it, the preview is an empty element that your CSS paints. */
+ children?: Snippet;
+ /** The CSS class names of the preview. */
+ class?: string;
+ /** A bindable reference to the preview element. */
+ element?: HTMLDivElement | null;
+};
+
+export type ColorPickerEyeDropperProps = Omit & {
+ /** The CSS class names of the button. */
+ class?: string;
+ /** A bindable reference to the button element. */
+ element?: HTMLButtonElement | null;
+};
diff --git a/packages/ui/src/lib/index.ts b/packages/ui/src/lib/index.ts
index b1c62ab..14a2f59 100644
--- a/packages/ui/src/lib/index.ts
+++ b/packages/ui/src/lib/index.ts
@@ -9,6 +9,7 @@ export { Button } from './button/index.js';
export { Checkbox } from './checkbox/index.js';
export { CheckboxGroup } from './checkbox-group/index.js';
export { Collapsible } from './collapsible/index.js';
+export { ColorPicker } from './color-picker/index.js';
export { ComboBox } from './combobox/index.js';
export { Calendar } from './calendar/index.js';
export { Clock } from './clock/index.js';
@@ -20,9 +21,11 @@ export { Drawer } from './drawer/index.js';
export { ListBox } from './listbox/index.js';
export { Menu } from './menu/index.js';
export { NumberField } from './numberfield/index.js';
+export { PinInput } from './pin-input/index.js';
export { Popover } from './popover/index.js';
export { Progress } from './progress/index.js';
export { RadioGroup } from './radio-group/index.js';
+export { Rating } from './rating/index.js';
export { Select } from './select/index.js';
export { Slider } from './slider/index.js';
export { Switch } from './switch/index.js';
@@ -57,6 +60,7 @@ export * from './checkbox-group/index.js';
export * from './avatar/index.js';
export * from './breadcrumbs/index.js';
export * from './collapsible/index.js';
+export * from './color-picker/index.js';
export * from './combobox/index.js';
export * from './calendar/index.js';
export * from './clock/index.js';
@@ -69,9 +73,11 @@ export * from './drawer/index.js';
export * from './listbox/index.js';
export * from './menu/index.js';
export * from './numberfield/index.js';
+export * from './pin-input/index.js';
export * from './popover/index.js';
export * from './progress/index.js';
export * from './radio-group/index.js';
+export * from './rating/index.js';
export * from './select/index.js';
export * from './slider/index.js';
export * from './switch/index.js';
diff --git a/packages/ui/src/lib/internal/localized-strings.ts b/packages/ui/src/lib/internal/localized-strings.ts
index 2b37d4d..37873aa 100644
--- a/packages/ui/src/lib/internal/localized-strings.ts
+++ b/packages/ui/src/lib/internal/localized-strings.ts
@@ -393,6 +393,150 @@ const LOCALIZED_STRINGS = {
fr: '{count} notifications',
de: '{count} Benachrichtigungen',
it: '{count} notifiche'
+ },
+ 'rating.valueText': {
+ en: '{value} of {count}',
+ es: '{value} de {count}',
+ pt: '{value} de {count}',
+ fr: '{value} sur {count}',
+ de: '{value} von {count}',
+ it: '{value} su {count}'
+ },
+ 'rating.empty': {
+ en: 'No rating',
+ es: 'Sin calificación',
+ pt: 'Sem classificação',
+ fr: 'Aucune note',
+ de: 'Keine Bewertung',
+ it: 'Nessuna valutazione'
+ },
+ 'rating.label': {
+ en: 'Rating',
+ es: 'Calificación',
+ pt: 'Classificação',
+ fr: 'Note',
+ de: 'Bewertung',
+ it: 'Valutazione'
+ },
+ 'pinInput.digitLabel': {
+ en: 'Digit {index} of {count}',
+ es: 'Dígito {index} de {count}',
+ pt: 'Dígito {index} de {count}',
+ fr: 'Chiffre {index} sur {count}',
+ de: 'Ziffer {index} von {count}',
+ it: 'Cifra {index} di {count}'
+ },
+ 'pinInput.characterLabel': {
+ en: 'Character {index} of {count}',
+ es: 'Carácter {index} de {count}',
+ pt: 'Caractere {index} de {count}',
+ fr: 'Caractère {index} sur {count}',
+ de: 'Zeichen {index} von {count}',
+ it: 'Carattere {index} di {count}'
+ },
+ 'pinInput.label': {
+ en: 'Verification code',
+ es: 'Código de verificación',
+ pt: 'Código de verificação',
+ fr: 'Code de vérification',
+ de: 'Bestätigungscode',
+ it: 'Codice di verifica'
+ },
+ 'colorPicker.label': {
+ en: 'Color',
+ es: 'Color',
+ pt: 'Cor',
+ fr: 'Couleur',
+ de: 'Farbe',
+ it: 'Colore'
+ },
+ 'colorPicker.hue': {
+ en: 'Hue',
+ es: 'Matiz',
+ pt: 'Matiz',
+ fr: 'Teinte',
+ de: 'Farbton',
+ it: 'Tonalità'
+ },
+ 'colorPicker.saturation': {
+ en: 'Saturation',
+ es: 'Saturación',
+ pt: 'Saturação',
+ fr: 'Saturation',
+ de: 'Sättigung',
+ it: 'Saturazione'
+ },
+ 'colorPicker.brightness': {
+ en: 'Brightness',
+ es: 'Brillo',
+ pt: 'Brilho',
+ fr: 'Luminosité',
+ de: 'Helligkeit',
+ it: 'Luminosità'
+ },
+ 'colorPicker.lightness': {
+ en: 'Lightness',
+ es: 'Luminosidad',
+ pt: 'Luminosidade',
+ fr: 'Clarté',
+ de: 'Helligkeit',
+ it: 'Chiarezza'
+ },
+ 'colorPicker.alpha': {
+ en: 'Alpha',
+ es: 'Alfa',
+ pt: 'Alfa',
+ fr: 'Alpha',
+ de: 'Alpha',
+ it: 'Alfa'
+ },
+ 'colorPicker.red': {
+ en: 'Red',
+ es: 'Rojo',
+ pt: 'Vermelho',
+ fr: 'Rouge',
+ de: 'Rot',
+ it: 'Rosso'
+ },
+ 'colorPicker.green': {
+ en: 'Green',
+ es: 'Verde',
+ pt: 'Verde',
+ fr: 'Vert',
+ de: 'Grün',
+ it: 'Verde'
+ },
+ 'colorPicker.blue': {
+ en: 'Blue',
+ es: 'Azul',
+ pt: 'Azul',
+ fr: 'Bleu',
+ de: 'Blau',
+ it: 'Blu'
+ },
+ 'colorPicker.hex': {
+ en: 'Hex',
+ es: 'Hex',
+ pt: 'Hex',
+ fr: 'Hex',
+ de: 'Hex',
+ it: 'Hex'
+ },
+ 'colorPicker.eyeDropper': {
+ en: 'Pick a color from the screen',
+ es: 'Tomar un color de la pantalla',
+ pt: 'Escolher uma cor da tela',
+ fr: 'Prendre une couleur sur l’écran',
+ de: 'Eine Farbe vom Bildschirm wählen',
+ it: 'Scegliere un colore dallo schermo'
+ },
+ 'colorPicker.swatches': {
+ en: 'Color swatches',
+ es: 'Muestras de color',
+ pt: 'Amostras de cor',
+ fr: 'Échantillons de couleur',
+ de: 'Farbfelder',
+ it: 'Campioni di colore'
}
} as const satisfies Record;
diff --git a/packages/ui/src/lib/pin-input/README.md b/packages/ui/src/lib/pin-input/README.md
new file mode 100644
index 0000000..1a12a42
--- /dev/null
+++ b/packages/ui/src/lib/pin-input/README.md
@@ -0,0 +1,55 @@
+# PinInput
+
+## Description
+
+`PinInput` is a form field for a short code: a PIN, or the verification code of a message. Each character has its own cell, and each cell is a real input. The telephone shows the correct keyboard, and a code from a message fills each cell with one touch.
+
+## Anatomy
+
+- `PinInput.Root`
+- `PinInput.Label`
+- `PinInput.Cell`
+
+```svelte
+
+ Verification code
+ {#each { length: 6 } as _, index (index)}
+
+ {/each}
+
+```
+
+## Usage guidelines
+
+- Use `bind:value` for the state, or `value` with `onChange` and `controlledValue` to hold it yourself.
+- Give one `PinInput.Cell` for each character of `length`.
+- Use `onComplete` for the work that follows the last character, for example the send of the form.
+- Use `otp` for a code that arrives in a message, and `mask` for a PIN the shoulder of a stranger must not read.
+- `type` decides which characters each cell accepts: `numeric`, `alphanumeric` or `alphabetic`. Give `pattern` for a test of your own.
+- The value has no holes. The characters fill the cells from the first one. A press on a cell past the first empty one goes to that one. A character that goes away takes the ones after it one cell to the left.
+
+## API reference
+
+- `PinInput.Root`
+ - `value?: string`
+ - `defaultValue?: string`
+ - `controlledValue?: boolean`
+ - `onChange?: (value, details) => void`
+ - `onComplete?: (value) => void`
+ - `length?: number`
+ - `type?: 'numeric' | 'alphanumeric' | 'alphabetic'`, `pattern?: RegExp`
+ - `otp?: boolean`, `mask?: boolean`, `placeholder?: string`
+ - `blurOnComplete?: boolean`
+ - `disabled?: boolean`, `readonly?: boolean`, `required?: boolean`, `invalid?: boolean`
+ - `name?: string`, `form?: string`
+- `PinInput.Cell`
+ - `index?: number`
+ - `aria-label?: string`
+
+## Accessibility
+
+- The root is a `role="group"` named by `PinInput.Label`, `aria-labelledby` or `aria-label`. With `otp` the group takes the name "Verification code" in the locale.
+- Each cell is an `` with its own name, for example "Digit 2 of 6", and with `inputmode`, `autocomplete` and `aria-invalid`.
+- The keyboard: a character moves the focus to the next cell. `Backspace` clears the cell, or the one before it. `Delete` clears the cell, the arrows move between the cells, and `Home` and `End` go to the ends. The horizontal arrows follow the text direction.
+- A paste goes across the cells from the cell it starts in, and the characters the type refuses are dropped.
+- The whole value in a form comes from a hidden input. A `