@stealthscale/specimen
v0.3.1
Published
Declares what a page of a catalogue is, and renders a component once per value of an axis.
Maintainers
Readme
@stealthscale/specimen
@stealthscale/specimen is what a specimen file is written with: the page declaration, the scenes,
the samples and matrices a scene renders, and the framed protocol through which a device shows one
sample. The catalogue plugin, @stealthscale/plugin-catalogue, renders the pages a build indexed.
Install
pnpm add -D @stealthscale/specimenThe package peers on @stealthscale/component-layout, @stealthscale/component-primitives,
@stealthscale/component-typography, @stealthscale/provider-i18n, @stealthscale/theme and
react. A package writing specimens does not declare it, the way a package writing specifications
does not declare the testing kits: a specimen runs in the catalogue and resolves through the
workspace root.
specimen
A specimen file's default export declares the page. The index plugin parses the call out of the source and never evaluates the module, so nothing here runs at build time.
import { Matrix, type Scene, specimen } from "@stealthscale/specimen";
import { Button } from "#button/index.ts";
const SIZES = ["sm", "md", "lg"] as const;
export const sizes: Scene = {
about: "The three steps every control in the library shares.",
draw: () => (
<Matrix direction="row" knob="size" of={SIZES}>
{(size) => <Button size={size}>Press</Button>}
</Matrix>
),
title: "Sizes",
};
export default specimen({
about: "The element a person presses.",
group: "Actions",
id: "actions/button",
imports: 'import { Button } from "@stealthscale/component-actions";',
scenes: [sizes],
});| Field | What it declares |
| ----------- | --------------------------------------------------------------------------- |
| id | The address of the page, required and unique across the catalogue |
| scenes | The scenes, in the order the page renders them |
| title | The page heading. The last segment of the identifier when absent |
| group | The group a navigation rail lists the page under. Empty when absent |
| about | The sentence or two the page opens with. Empty when absent |
| imports | The statement the page opens with. No import line when absent |
| namespace | The catalogue namespace the words are keys in. Shown as written when absent |
The page lists its scenes rather than gathering them from the file's exports, because a module
returns its names in alphabetical order and a page written Variants, States, Anatomy would be read
back Anatomy, States, Variants. scene() declares one the way specimen() declares a page, for a
file that wants the scene's fields type-checked where the scene is written.
A scene declares title and draw, and may declare about, example, source, frame and
viewport. draw takes a component, not a node, so a stateful scene calls its hooks in its own
render. frame sets the card framing. inset keeps the card padding, bleed removes the padding
so a panel reaches the card edges, and bare also removes the card surface. viewport makes the
scene fill the device window. sourceOf(scene) returns the text the scene's Source control shows.
Examples
The Source control of a scene shows an example file. An example file is
<component>/examples/<name>.example.tsx and exports one component, written as the code a consumer
copies. Its text goes through useWords, so the catalogue renders it in the reader's locale. The
specimen plugin appends the file's text to the module as the source export, with # imports
rewritten to the package name.
import * as filtered from "#tag/examples/filters.example.tsx";
import * as removable from "#tag/examples/removable.example.tsx";
export const filters: Scene = {
about: "tag.filters.about",
draw: filtered.Filters,
example: filtered,
title: "tag.filters.title",
};
scenesOf<RootProps>(recipe, {
draw: (props) => <removable.Removable {...props} />,
example: removable,
namespace: "tag",
});A generated scene writes the props of its first cell into the example, and a hand-written scene
writes its props. The example component takes one parameter named props and spreads it with
{...props}. The kit replaces each spread with the attributes and removes the parameter, so the
Source of the palette scene reads <Tag.Root palette="primary" variant="solid">. An object prop is
written as an object literal, such as positioning={{ placement: "top" }}. A hand-written scene
without props shows the example file unchanged. Staging that a consumer does not write, such as a
Room, goes in the scene's draw. scenesOf takes example at page level and per axis, and an
axis setting takes precedence. A stated source takes precedence over example.
Each component package runs src/examples.spec.ts, which renders every example and asserts that axe
reports no violation.
The words
A page's words are keys in the specimen namespace, under a prefix of the page's own. The package
that writes the page keeps them beside it as locales/<language>/specimen/<page>.json, which the
i18n plugin places under <page> in the namespace. The page's title, its opening and each scene's
title and opening are keys the catalogue resolves in the language a reader chose, and a key with no
entry is shown as the key, so a page written in plain words reads as written. A page whose words are
in another namespace names it with namespace.
A scene reads its words through useWords, bound to the namespace under the prefix it names, so a
specimen imports nothing from the i18n foundation:
import { useWords } from "@stealthscale/specimen";
function Looks(): ReactElement {
const { t } = useWords("button");
return <Button>{t("publish")}</Button>;
}NAMESPACE is the namespace's name, specimen.
Sample and Board
Sample renders one component under a caption. of is the value in the caption and knob is the
prop the value was set on.
variantis the look of the box around the component:plain,outline,subtle,surfaceorinverted.invertedfills the box withbg.invertedand sets the inherited ink tofg.inverted. The other looks set no text colour.placealigns the component in the box:start,center,endorstretch. The box fills the cell at every value.spansets the number of board columns the sample spans.
grounded(tone, cell) returns the cell in an inverted sample when tone is inverted, and the
cell unchanged for any other tone. A scene of a tone axis passes each cell through it, because the
inverted ink fails the text contrast ratio on the page.
tone: { draw: (props) => grounded(props.tone, <sentence.Sentence {...props} />) },Board lays the samples a specimen writes out on the library's grid, and states the look of every
sample on it once. It takes everything Grid.Root takes, with equal columns of the smallest measure
and the widest gap by default, and place and variant for the samples below it. A sample states
either itself to differ from the board.
<Board columns="fit-sm">
<Sample of="a row that wraps">
<Toolbar.Root>…</Toolbar.Root>
</Sample>
<Sample of="the same, held narrow" variant="outline">
<Room size="xs">
<Toolbar.Root>…</Toolbar.Root>
</Room>
</Sample>
</Board>CAPTION is the size and the ink of a caption, and captioned(value, knob) writes the prop in the
muted ink before the value in full ink. A part that captions a value outside a sample, such as the
catalogue's device picker, uses both, so its caption reads like a cell's.
Matrix
Matrix renders one captioned cell per value of an axis, or one per pair of values where a second
axis crosses the first. Each cell is a sample, so the matrix takes the two axes a board takes for
its cells as well.
| Prop | What it does |
| ----------- | ------------------------------------------------------------------------ |
| of | The values, in the order the cells are rendered |
| knob | The prop the axis turns, written before each value in the muted ink |
| label | Converts a value into the name its cell is captioned with |
| across | A second axis, whose values run across each row of the first |
| direction | Which way the cells of one axis run, row or column. row by default |
| columns | The board's columns the cells of one axis are laid on |
| place | Where the component is placed in each cell's box, as a sample takes it |
| variant | How each cell's box looks, as a sample takes it |
valuesOf(recipe, axis) reads the values an axis offers off a recipe, typed as the recipe's own
literals, so a page renders every value the theme can move and misses none added later. The size
axis comes back in the scale's order rather than the recipe's, because the lint sorts a recipe's
keys alphabetically.
<Matrix knob="variant" of={valuesOf(recipe, "variant")}>
{(variant) => <Button variant={variant}>Publish</Button>}
</Matrix><Matrix across={{ knob: "size", of: SIZES }} knob="variant" of={VARIANTS}>
{(variant, size) => (
<Button size={size} variant={variant}>
Publish
</Button>
)}
</Matrix>One axis is a row of captioned cells that wraps where it runs out of room, so a row of sizes folds onto the next line on a narrow page, or a column of them. Two axes are one captioned row per value of the first axis, with one captioned cell per value of the second. A third axis nests one matrix in another. The primitives package's scroll area scrolls a grid of two axes that is wider than the card, and its viewport is a region with a tab stop while the grid overflows.
Tile and Room
Tile is a block that stands in for content, so a specimen of a layout has boxes to arrange rather
than bare words. Room is a box held to one of the page's measures, sm by default, for a scene
that only reads once the width runs out: a row that wraps, a heading cut to one line, a label beside
its control.
<Room size="xs">
<Heading truncate>A long title for a page that could have used a shorter one</Heading>
</Room>Contained and Focused
Contained is a box with contain: layout, which makes it the containing block of a
position: fixed descendant. Render an example with a control that is fixed to the window under
keyboard focus, such as a skip link, inside it. Tab then reveals the control at the box's corner and
not over the catalogue.
Focused is a Contained that sets data-focus-visible on its first focusable descendant after it
mounts. Every _focusVisible condition matches that attribute, so the descendant renders its focus
styles in a still image without taking focus. Use it for a focus style on a control that is visible
at rest, such as the ring on a toolbar's tab stop. Do not use it for a control that is hidden until
focus, because the staged control then reads as always visible. target takes a selector for a
scene whose first focusable descendant is not the control it shows, such as a splitter's trigger
after a panel's scroll area.
<Contained>
<SkipNav.Link href="#results">Skip to results</SkipNav.Link>
</Contained>;
<Focused>
<Toolbar />
</Focused>;
<Focused target="[role=separator]">
<Splitter />
</Focused>;Screen
Screen is the window an application's shell or a page renders in, inside a scene. Use it in a
specimen's draw and never in an example.
- The box has a hairline edge and
l2corners and clips its content to the corners. Its fill isbg.panel. sizesets the height to a named size:xs20rem,sm24rem,md28rem,lg32rem,xl36rem. It sets--app-shell-window-heightto the height inside the two hairlines, so a shell and its sheet end at the box's lower edge. Withoutsizethe box is as tall as its content.contain: layoutmakes the box the containing block of aposition: fixeddescendant, so a narrow shell's sheet opens inside the box.scrollsrenders the content in the primitives package's scroll area, which fills the box, for a shell that scrolls the window. Its viewport takes no tab stop, because a shell's controls scroll into view as they take focus.
draw: () => (
<Screen size="lg">
<console.Console />
</Screen>
),The framed protocol
A device shows one sample of a scene in a frame that loads the catalogue's framed page. The scene renders inside the frame as it does on the page, and a matrix, a board or a sample there renders the one sample the frame's address picks, without a caption or a box.
- The address is the fragment the frame loads:
#actions/button/1?v=0&x=2names the page, the position of the scene on it, and the position picked on each axis,vdown the first andxacross the second, orsfor a board's sample.writeAddress(address)writes it, andreadAddress(fragment)reads it back or returns undefined. FramedProviderpasses the pick to the scene, anduseFramed()reads it, undefined on the page itself.- A matrix or a board inside a frame posts the axes it offers to the page around the frame, as a
Reportwith the typeREPORTED, the fragment and oneChoiceper axis.isReport(data)tells a report from any other message.
The recipes it states
Every part the kit renders is a component of the library: the caption is Text, a board is
Grid.Root. The matrix, the sample, the tile, the room, the screen and the contained box state a
recipe each, published as a preset from @stealthscale/specimen/theme, and every value in them is a
semantic token, so a theme that moves the library moves the specimens with it.
Types
Axis<Value> describes an axis: of, knob and label. MatrixProps<Value> extends it with
children, direction, columns and the sample's two axes. SampleProps and BoardProps
describe a sample and a board. Specimen, Scene and Frame describe what a page declares.
Address, Pick, Report and Choice describe the framed protocol.
Licence
MIT. See LICENSE.
