shelving 1.232.0 → 1.234.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.232.0",
3
+ "version": "1.234.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,14 +9,14 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.4.15",
12
+ "@biomejs/biome": "^2.4.16",
13
13
  "@google-cloud/firestore": "^8.6.0",
14
14
  "@heroicons/react": "^2.2.0",
15
15
  "@types/bun": "^1.3.14",
16
16
  "@types/react": "^19.2.15",
17
17
  "@types/react-dom": "^19.2.3",
18
- "@typescript/native-preview": "^7.0.0-dev.20260523.1",
19
- "firebase": "^12.13.0",
18
+ "@typescript/native-preview": "^7.0.0-dev.20260527.2",
19
+ "firebase": "^12.14.0",
20
20
  "react": "^19.3.0-canary-fef12a01-20260413",
21
21
  "react-dom": "^19.3.0-canary-fef12a01-20260413",
22
22
  "typescript": "^5.9.3"
@@ -1,3 +1,4 @@
1
+ import { sanitizeWord } from "../util/string.js";
1
2
  import { NULLABLE } from "./NullableSchema.js";
2
3
  import { StringSchema } from "./StringSchema.js";
3
4
  const R_MATCH = /^[a-z0-9](?:[a-zA-Z0-9._+-]{0,62}[a-zA-Z0-9])?@(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,3}(?:[a-z]{2,63}|xn--[a-z0-9-]{0,58}[a-z0-9])$/;
@@ -32,7 +33,8 @@ export class EmailSchema extends StringSchema {
32
33
  });
33
34
  }
34
35
  sanitize(str) {
35
- return super.sanitize(str).toLowerCase();
36
+ // Email addresses never contain whitespace, so strip it entirely, then lowercase (RFC says addresses should be case-insensitive).
37
+ return sanitizeWord(str).toLowerCase();
36
38
  }
37
39
  }
38
40
  /** Valid email, e.g. `test@test.com` */
@@ -14,6 +14,7 @@ export declare class URISchema extends StringSchema {
14
14
  readonly schemes: URISchemes;
15
15
  constructor({ one, title, schemes, ...options }: URISchemaOptions);
16
16
  validate(unsafeValue: unknown): URIString;
17
+ sanitize(str: string): string;
17
18
  format(value: string): string;
18
19
  }
19
20
  /** Valid URI string, e.g. `https://www.google.com` */
@@ -1,4 +1,5 @@
1
1
  import { formatURI } from "../util/format.js";
2
+ import { sanitizeWord } from "../util/string.js";
2
3
  import { getURI, HTTP_SCHEMES } from "../util/uri.js";
3
4
  import { NULLABLE } from "./NullableSchema.js";
4
5
  import { StringSchema } from "./StringSchema.js";
@@ -31,6 +32,10 @@ export class URISchema extends StringSchema {
31
32
  throw `Invalid ${this.one} scheme`;
32
33
  return uri.href;
33
34
  }
35
+ sanitize(str) {
36
+ // URIs never contain whitespace (a real space must be `%20`-encoded), so strip it entirely.
37
+ return sanitizeWord(str);
38
+ }
34
39
  format(value) {
35
40
  return formatURI(value, this.format);
36
41
  }
@@ -17,6 +17,7 @@ export declare class URLSchema extends StringSchema {
17
17
  readonly schemes: URISchemes;
18
18
  constructor({ one, title, base, schemes, ...options }: URLSchemaOptions);
19
19
  validate(unsafeValue: unknown): URLString;
20
+ sanitize(str: string): string;
20
21
  format(value: string): string;
21
22
  }
22
23
  /** Valid URL string, e.g. `https://www.google.com` */
@@ -1,4 +1,5 @@
1
1
  import { formatURL } from "../util/format.js";
2
+ import { sanitizeWord } from "../util/string.js";
2
3
  import { HTTP_SCHEMES } from "../util/uri.js";
3
4
  import { getURL } from "../util/url.js";
4
5
  import { NULLABLE } from "./NullableSchema.js";
@@ -34,6 +35,10 @@ export class URLSchema extends StringSchema {
34
35
  throw `Invalid ${this.one} scheme`;
35
36
  return url.href;
36
37
  }
38
+ sanitize(str) {
39
+ // URLs never contain whitespace (a real space must be `%20`-encoded), so strip it entirely.
40
+ return sanitizeWord(str);
41
+ }
37
42
  format(value) {
38
43
  return formatURL(value, this.base, this.format);
39
44
  }
package/ui/README.md CHANGED
@@ -155,6 +155,37 @@ Other tokens (`--*-padding`, `--*-spacing`, `--*-radius`, `--*-font`, `--*-size`
155
155
 
156
156
  This split is deliberate. The rebind is the right tool when an identity needs to propagate; for everything else, plain CSS inheritance (or no inheritance at all) is the right tool.
157
157
 
158
+ ### Retheming via the global scale
159
+
160
+ The rebind pattern has a powerful consequence: because every surface component rebinds the scale from `inherit`, the page-level `:root` scale is the **cascade root they all fall back to**. Retinting a step at `:root` repaints every surface component at once — and *identically*, so a standalone `<Preformatted>` matches one nested in a `<Card>`, and both match the `<Card>` itself. This is almost always preferable to overriding each component's own hook (`--card-color-light`, `--preformatted-color-light`, …) one by one, which only themes that single component and leaves its siblings on the grey defaults.
161
+
162
+ **But retint one step at a time, and know what else reads it.** The global scale isn't surfaces-only — the page baseline reads from it too. In `base.css`:
163
+
164
+ ```css
165
+ body { color: var(--color-dark); background: var(--color-white); }
166
+ ```
167
+
168
+ All body copy (Titles, Headings, Paragraphs, lists) has no `color` of its own; it inherits this baseline. So moving `--color-dark` at `:root` recolours **every word on the page**, not just text sitting on a card. Likewise `--color-vivid` tints borders and accents app-wide. Retint only the step whose reach you actually want:
169
+
170
+ - `--color-light` — **surfaces** (Card / Preformatted / Tag / Code backgrounds). Safe to retint broadly; nothing paints page text or the page background from it.
171
+ - `--color-vivid` — borders and accents everywhere. Retint only if you want app-wide accent recolouring.
172
+ - `--color-dark` — **the page text colour**, via the `body` baseline above. Retinting this is a whole-page text recolour; usually not what a "themed surfaces" look wants.
173
+ - `--color-black` / `--color-white` — the page extremes (max-contrast text, page background). Leave unless inverting (e.g. dark mode).
174
+
175
+ The docs theme wants peach surfaces with normal near-black text, so it retints **only** `--color-light`:
176
+
177
+ ```css
178
+ :root {
179
+ /* Surfaces go peach; text and the page background stay the library defaults. */
180
+ --color-light: color-mix(in srgb, #ff7a1a 14%, white);
181
+ }
182
+ ```
183
+
184
+ Two more rules keep a theme clean:
185
+
186
+ - **If you do move a whole hue, move the anchor.** The `--light-<hue>` / `--dark-<hue>` tokens are defined in `base.css` as expressions over `--vivid-<hue>`, resolved lazily at use-time. Overriding `--vivid-orange` at `:root` re-tints the whole orange family for free, so `var(--light-orange)` / `var(--dark-orange)` stay coherent.
187
+ - **Pin the exceptions back — and pin *every* step the component paints.** A component that should resist a global retint sets its own hooks. But a component rebinds the *whole* scale, and any step you leave unpinned still inherits the page colour. The docs site keeps Buttons purple by pinning both steps the default variant paints — `--button-color-light: var(--light-purple)` (background) and `--button-color-vivid: var(--vivid-purple)` (border/label) — plus `--button-color-white` for the `strong` label. Pinning only `vivid` would leave the default button's `bg=light` background inheriting the page peach: a purple-bordered peach button. Check which steps the variant in use actually paints (default = `light`+`vivid`, `strong` = `vivid`+`white`) and pin all of them.
188
+
158
189
  ### How `:first-child` / `:last-child` margin overrides work
159
190
 
160
191
  Every block-level component zeros its outer margins when it's the first or last child of its container — otherwise a Heading at the top of a Card would leave a strip of unwanted space. These rules live in `@layer overrides`, which beats every other layer including `variants`, so a `<Card space-large>` still collapses its abutting edges correctly.
@@ -6,9 +6,10 @@ import { Preformatted } from "../block/Preformatted.js";
6
6
  import { Subheading } from "../block/Subheading.js";
7
7
  import { Code } from "../inline/Code.js";
8
8
  import { Flex } from "../style/Flex.js";
9
- import { DocumentationKind } from "./DocumentationKind.js";
9
+ import { DocumentationKind, getDocumentationKindColor } from "./DocumentationKind.js";
10
10
  /** Card renderer for a `tree-documentation` element — a summary card showing the heading, signatures, and description. */
11
11
  export function DocumentationCard({ path, title, name, kind, description, signatures }) {
12
12
  const href = joinPath(path, name);
13
- return (_jsxs(Card, { href: href, children: [_jsx(Subheading, { children: _jsxs(Flex, { left: true, wrap: true, children: [_jsx(Code, { children: title ?? name }), kind && _jsx(DocumentationKind, { kind: kind })] }) }), signatures?.map(sig => (_jsx(Preformatted, { children: sig }, sig))), description && _jsx(Paragraph, { children: description })] }));
13
+ const color = kind ? getDocumentationKindColor(kind) : undefined;
14
+ return (_jsxs(Card, { href: href, ...(color ? { [color]: true } : {}), children: [_jsx(Subheading, { children: _jsxs(Flex, { left: true, wrap: true, children: [_jsx(Code, { children: title ?? name }), kind && _jsx(DocumentationKind, { kind: kind })] }) }), signatures?.map(sig => (_jsx(Preformatted, { children: sig }, sig))), description && _jsx(Paragraph, { children: description })] }));
14
15
  }
@@ -7,7 +7,7 @@ import { Preformatted } from "../block/Preformatted.js";
7
7
  import { Subheading } from "../block/Subheading.js";
8
8
  import { Code } from "../inline/Code.js";
9
9
  import { Flex } from "../style/Flex.js";
10
- import { DocumentationKind } from "./DocumentationKind.js";
10
+ import { DocumentationKind, getDocumentationKindColor } from "./DocumentationKind.js";
11
11
 
12
12
  interface DocumentationCardProps extends DocumentationElementProps {
13
13
  path: AbsolutePath;
@@ -16,8 +16,9 @@ interface DocumentationCardProps extends DocumentationElementProps {
16
16
  /** Card renderer for a `tree-documentation` element — a summary card showing the heading, signatures, and description. */
17
17
  export function DocumentationCard({ path, title, name, kind, description, signatures }: DocumentationCardProps): ReactNode {
18
18
  const href = joinPath(path, name);
19
+ const color = kind ? getDocumentationKindColor(kind) : undefined;
19
20
  return (
20
- <Card href={href}>
21
+ <Card href={href} {...(color ? { [color]: true } : {})}>
21
22
  <Subheading>
22
23
  <Flex left wrap>
23
24
  <Code>{title ?? name}</Code>
@@ -1,9 +1,15 @@
1
1
  import type { ReactElement } from "react";
2
+ import type { Color } from "../style/Color.js";
2
3
  /** Props for `DocumentationKind`. */
3
4
  export interface DocumentationKindProps {
4
5
  /** The documentation kind (e.g. `"function"`, `"class"`, `"interface"`, `"type"`, `"constant"`, `"method"`, `"property"`). */
5
6
  readonly kind: string;
6
7
  }
8
+ /**
9
+ * Get the raw colour variant for a documented symbol's `kind`, or `undefined` for an unknown kind.
10
+ * - Shared source of truth so the kind tag and its surrounding card pick the same hue.
11
+ */
12
+ export declare function getDocumentationKindColor(kind: string): Color | undefined;
7
13
  /**
8
14
  * Colour-coded tag for a documented symbol's kind.
9
15
  * - Thin wrapper over `<Tag>` that maps the kind string to a raw colour variant.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Tag } from "../misc/Tag.js";
3
- /** Mapping from a documented symbol's `kind` to its tag colour. */
3
+ /** Mapping from a documented symbol's `kind` to its raw colour variant. */
4
4
  const KIND_COLOR = {
5
5
  module: "red",
6
6
  function: "blue",
@@ -11,6 +11,13 @@ const KIND_COLOR = {
11
11
  method: "orange",
12
12
  property: "yellow",
13
13
  };
14
+ /**
15
+ * Get the raw colour variant for a documented symbol's `kind`, or `undefined` for an unknown kind.
16
+ * - Shared source of truth so the kind tag and its surrounding card pick the same hue.
17
+ */
18
+ export function getDocumentationKindColor(kind) {
19
+ return KIND_COLOR[kind];
20
+ }
14
21
  /**
15
22
  * Colour-coded tag for a documented symbol's kind.
16
23
  * - Thin wrapper over `<Tag>` that maps the kind string to a raw colour variant.
@@ -18,6 +25,6 @@ const KIND_COLOR = {
18
25
  * @example <DocumentationKind kind="function" />
19
26
  */
20
27
  export function DocumentationKind({ kind }) {
21
- const color = KIND_COLOR[kind];
28
+ const color = getDocumentationKindColor(kind);
22
29
  return _jsx(Tag, { ...(color ? { [color]: true } : {}), children: kind });
23
30
  }
@@ -8,7 +8,7 @@ export interface DocumentationKindProps {
8
8
  readonly kind: string;
9
9
  }
10
10
 
11
- /** Mapping from a documented symbol's `kind` to its tag colour. */
11
+ /** Mapping from a documented symbol's `kind` to its raw colour variant. */
12
12
  const KIND_COLOR: { readonly [K in string]?: Color } = {
13
13
  module: "red",
14
14
  function: "blue",
@@ -20,6 +20,14 @@ const KIND_COLOR: { readonly [K in string]?: Color } = {
20
20
  property: "yellow",
21
21
  };
22
22
 
23
+ /**
24
+ * Get the raw colour variant for a documented symbol's `kind`, or `undefined` for an unknown kind.
25
+ * - Shared source of truth so the kind tag and its surrounding card pick the same hue.
26
+ */
27
+ export function getDocumentationKindColor(kind: string): Color | undefined {
28
+ return KIND_COLOR[kind];
29
+ }
30
+
23
31
  /**
24
32
  * Colour-coded tag for a documented symbol's kind.
25
33
  * - Thin wrapper over `<Tag>` that maps the kind string to a raw colour variant.
@@ -27,6 +35,6 @@ const KIND_COLOR: { readonly [K in string]?: Color } = {
27
35
  * @example <DocumentationKind kind="function" />
28
36
  */
29
37
  export function DocumentationKind({ kind }: DocumentationKindProps): ReactElement {
30
- const color = KIND_COLOR[kind];
38
+ const color = getDocumentationKindColor(kind);
31
39
  return <Tag {...(color ? { [color]: true } : {})}>{kind}</Tag>;
32
40
  }
package/util/string.d.ts CHANGED
@@ -42,6 +42,15 @@ export declare function joinStrings(strs: Iterable<string> & NotString, joiner?:
42
42
  * @example santizeString("\x00Nice! "); // Returns `"Nice!"`
43
43
  */
44
44
  export declare function sanitizeText(str: string): string;
45
+ /**
46
+ * Sanitize a single word of text.
47
+ * - Used when you're sanitising a value that can never contain whitespace, e.g. an email address or token.
48
+ * - Remove all control characters (like `sanitizeText()`).
49
+ * - Strip all whitespace entirely (rather than collapsing runs to a single space like `sanitizeText()`).
50
+ *
51
+ * @example sanitizeWord("\x00 a b c "); // Returns `"abc"`
52
+ */
53
+ export declare function sanitizeWord(str: string): string;
45
54
  /**
46
55
  * Sanitize multiple lines of text.
47
56
  * - Used when you're sanitising a multi-line input, e.g. a description for something.
package/util/string.js CHANGED
@@ -69,6 +69,19 @@ export function sanitizeText(str) {
69
69
  .replace(/\s+/gu, " ") // Normalise runs of whitespace to one ` ` space.
70
70
  .trim(); // Trim whitespace from the start and end of the string.
71
71
  }
72
+ /**
73
+ * Sanitize a single word of text.
74
+ * - Used when you're sanitising a value that can never contain whitespace, e.g. an email address or token.
75
+ * - Remove all control characters (like `sanitizeText()`).
76
+ * - Strip all whitespace entirely (rather than collapsing runs to a single space like `sanitizeText()`).
77
+ *
78
+ * @example sanitizeWord("\x00 a b c "); // Returns `"abc"`
79
+ */
80
+ export function sanitizeWord(str) {
81
+ return str
82
+ .replace(/[^\P{C}\s]/gu, "") // Strip control characters (except whitespace).
83
+ .replace(/\s+/gu, ""); // Strip all whitespace entirely.
84
+ }
72
85
  /**
73
86
  * Sanitize multiple lines of text.
74
87
  * - Used when you're sanitising a multi-line input, e.g. a description for something.