@forwardreach/saas-ui 0.12.0 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.13.0
4
+
5
+ ### Minor Changes
6
+
7
+ - **The chrome a host draws its own main pages with, so a page built only from shared
8
+ parts is indistinguishable from one the host draws by hand.** Reviewing ReachMe's
9
+ PeopleThread extension beside PeopleThread's Timeline found three gaps in this
10
+ package — no reference pill, no shared page column, and a `PageHeader` whose default
11
+ was not the host's heading shape — and each was being filled locally, in the wrong
12
+ place. This release fills them here.
13
+
14
+ - **Pill tokens.** `--ssui-pill-<tone>-bg` / `-text` for five tones: `reference` (a
15
+ link to a record), `tag`, `attribute` (a field), `date`, `category` (a grouping
16
+ tag). Every default is a _derivation_ of the status and accent ramps, never a
17
+ literal, so a consumer that has mapped the base contract sees a coherent pair in
18
+ both themes before it maps a pill token. That is now the rule for any token added
19
+ for a role a consumer may already color, and a test enforces it. A second test
20
+ holds the documented token list equal to the shipped declarations, so a consumer
21
+ can check its mapping for completeness against the docs.
22
+ - **`PageContainer`.** The centered page column: `width="narrow"` (a reading
23
+ column, 48rem), `"wide"` (a working column, 64rem), or `"full"` (a workspace, no
24
+ gutters). A page opts in; a host never wraps another product's page in it.
25
+ - **`Card`.** The bordered surface — `--ssui-surface` on `--ssui-border` at
26
+ `--ssui-radius-lg` — with `elevated`, an `interactive` border-hover for a card
27
+ whose parts are clickable, and `asChild`. Padding is the consumer's. `ListShell`
28
+ is a different thing and stays.
29
+ - **`Pill`.** An inline reference chip in the five tones, `align-baseline` so it
30
+ sits in a line of text, with `asChild` so the consumer's own link carries the
31
+ presentation. The `reference` tone carries a hairline border, which is what
32
+ separates a chip that reaches its record from one that is only a label. **The
33
+ previous `Pill` export was an alias of `Badge`**; the gallery was its only
34
+ consumer and is updated here, no product consumed it, and the name now means this
35
+ component. A badge-shaped pill is `Badge variant="outline"`.
36
+ - **`PageHeader` takes the host's heading shape.** The title ramp is now
37
+ `text-xl sm:text-2xl` (was `text-2xl`); the bottom rule is an opt-in `divider`
38
+ prop (was always drawn); a new `aside` slot renders on the title's baseline at
39
+ the right edge of the title row, for context that is not an action — a date, a
40
+ count — while `actions` keeps its own slot, so the two never share a line at
41
+ narrow widths. From `sm` up the header row now wraps: actions too wide to sit
42
+ beside the full title move to a row beneath it instead of shrinking the title
43
+ to nothing. **This is a visual change for every consumer that relied on the
44
+ rule and the larger title.** Pass `divider` where the rule is wanted; both known
45
+ consumers do so in the commit that takes this version.
46
+
3
47
  ## 0.12.0
4
48
 
5
49
  ### Minor Changes
@@ -47,7 +91,7 @@
47
91
  visible change to an existing component: `Combobox` renders the same list, so
48
92
  its popup picks all three corrections up. Options take the full text colour
49
93
  rather than the muted one, which came from a popup that hangs under a field
50
- holding the answer; where the popup *is* the choice, options dimmer than the
94
+ holding the answer; where the popup _is_ the choice, options dimmer than the
51
95
  heading over them read as less available than the label for them. The current
52
96
  option carries a check and a medium weight rather than a fill alone, because
53
97
  that fill is `--ssui-surface-muted` on `--ssui-surface-elevated` — a clear step
@@ -6,4 +6,3 @@ export declare const badgeVariants: (props?: ({
6
6
  export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement>, VariantProps<typeof badgeVariants> {
7
7
  }
8
8
  export declare const Badge: React.ForwardRefExoticComponent<BadgeProps & React.RefAttributes<HTMLSpanElement>>;
9
- export declare const Pill: React.ForwardRefExoticComponent<BadgeProps & React.RefAttributes<HTMLSpanElement>>;
@@ -20,4 +20,3 @@ export const badgeVariants = cva("inline-flex items-center gap-1 rounded-full bo
20
20
  });
21
21
  export const Badge = React.forwardRef(({ className, variant, ...props }, ref) => (_jsx("span", { ref: ref, className: cn(badgeVariants({ variant, className })), ...props })));
22
22
  Badge.displayName = "Badge";
23
- export const Pill = Badge;
@@ -0,0 +1,15 @@
1
+ import * as React from "react";
2
+ export interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
3
+ asChild?: boolean;
4
+ /** Sit on `--ssui-surface-elevated` instead of `--ssui-surface`. */
5
+ elevated?: boolean;
6
+ /** Strengthen the border on hover, for a card whose parts are clickable. Nothing else changes. */
7
+ interactive?: boolean;
8
+ }
9
+ /**
10
+ * The bordered surface: `--ssui-surface` on `--ssui-border` at `--ssui-radius-lg`. Padding is
11
+ * the consumer's (`className`), because a card holding a row and a card holding a form pad
12
+ * differently and a default would be overridden everywhere. `ListShell` is a different thing —
13
+ * the divided list container with header, toolbar, footer, and empty slots — and both stay.
14
+ */
15
+ export declare const Card: React.ForwardRefExoticComponent<CardProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,17 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ /**
6
+ * The bordered surface: `--ssui-surface` on `--ssui-border` at `--ssui-radius-lg`. Padding is
7
+ * the consumer's (`className`), because a card holding a row and a card holding a form pad
8
+ * differently and a default would be overridden everywhere. `ListShell` is a different thing —
9
+ * the divided list container with header, toolbar, footer, and empty slots — and both stay.
10
+ */
11
+ export const Card = React.forwardRef(({ asChild = false, className, elevated = false, interactive = false, ...props }, ref) => {
12
+ const Comp = asChild ? Slot : "div";
13
+ return (_jsx(Comp, { ref: ref, className: cn("rounded-[var(--ssui-radius-lg)] border border-[color:var(--ssui-border)] text-[color:var(--ssui-text)]", elevated
14
+ ? "bg-[color:var(--ssui-surface-elevated)]"
15
+ : "bg-[color:var(--ssui-surface)]", interactive && "transition-colors hover:border-[color:var(--ssui-border-strong)]", className), ...props }));
16
+ });
17
+ Card.displayName = "Card";
@@ -4,6 +4,7 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./card.js";
7
8
  export * from "./checkbox.js";
8
9
  export * from "./choice-card.js";
9
10
  export * from "./collapsible.js";
@@ -23,7 +24,9 @@ export * from "./input.js";
23
24
  export * from "./list-shell.js";
24
25
  export * from "./login.js";
25
26
  export * from "./overflow-menu.js";
27
+ export * from "./page-container.js";
26
28
  export * from "./page-header.js";
29
+ export * from "./pill.js";
27
30
  export * from "./popover.js";
28
31
  export * from "./rail-toggle.js";
29
32
  export * from "./request-access.js";
@@ -4,6 +4,7 @@ export * from "./avatar.js";
4
4
  export * from "./badge.js";
5
5
  export * from "./brand-icons.js";
6
6
  export * from "./button.js";
7
+ export * from "./card.js";
7
8
  export * from "./checkbox.js";
8
9
  export * from "./choice-card.js";
9
10
  export * from "./collapsible.js";
@@ -23,7 +24,9 @@ export * from "./input.js";
23
24
  export * from "./list-shell.js";
24
25
  export * from "./login.js";
25
26
  export * from "./overflow-menu.js";
27
+ export * from "./page-container.js";
26
28
  export * from "./page-header.js";
29
+ export * from "./pill.js";
27
30
  export * from "./popover.js";
28
31
  export * from "./rail-toggle.js";
29
32
  export * from "./request-access.js";
@@ -0,0 +1,7 @@
1
+ import * as React from "react";
2
+ export type PageContainerWidth = "narrow" | "wide" | "full";
3
+ export interface PageContainerProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ asChild?: boolean;
5
+ width?: PageContainerWidth;
6
+ }
7
+ export declare const PageContainer: React.ForwardRefExoticComponent<PageContainerProps & React.RefAttributes<HTMLDivElement>>;
@@ -0,0 +1,20 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ /**
6
+ * The centered column a page sits in. The widths are a vocabulary, not numbers: `narrow` is a
7
+ * reading column (a timeline, a record), `wide` a working column (a collection, a list of
8
+ * things to act on), and `full` a workspace that fills what it is given (an inbox, an editor).
9
+ * `full` exists so a full-bleed page still declares that it made a choice.
10
+ */
11
+ const widthClasses = {
12
+ narrow: "mx-auto w-full max-w-3xl px-4 py-5 md:px-8 md:py-8",
13
+ wide: "mx-auto w-full max-w-5xl px-4 py-5 md:px-8 md:py-8",
14
+ full: "w-full"
15
+ };
16
+ export const PageContainer = React.forwardRef(({ asChild = false, className, width = "wide", ...props }, ref) => {
17
+ const Comp = asChild ? Slot : "div";
18
+ return (_jsx(Comp, { ref: ref, className: cn(widthClasses[width], className), "data-width": width, ...props }));
19
+ });
20
+ PageContainer.displayName = "PageContainer";
@@ -1,10 +1,30 @@
1
1
  import * as React from "react";
2
2
  export interface PageHeaderProps extends Omit<React.HTMLAttributes<HTMLElement>, "title"> {
3
3
  actions?: React.ReactNode;
4
+ /**
5
+ * Context that is not an action — today's date, a count — rendered on the title's baseline
6
+ * at the right edge of the title row. The slot sets no type; size it as the page wants.
7
+ * `actions` keeps its own slot, so the two never share a line at narrow widths.
8
+ */
9
+ aside?: React.ReactNode;
4
10
  breadcrumbs?: React.ReactNode;
5
11
  description?: React.ReactNode;
12
+ /** Draw the bottom rule. Off by default: a main page has none, a settings page opts in. */
13
+ divider?: boolean;
6
14
  eyebrow?: React.ReactNode;
7
15
  metadata?: React.ReactNode;
8
16
  title: React.ReactNode;
9
17
  }
10
- export declare function PageHeader({ actions, breadcrumbs, className, description, eyebrow, metadata, title, ...props }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
18
+ /**
19
+ * The page title in the shape a host draws its own main pages in: `text-xl` rising to
20
+ * `text-2xl` at the small breakpoint, no rule beneath unless asked for. A page built only
21
+ * from shared parts should be indistinguishable from one the host draws by hand, which is
22
+ * why the default is the host's shape and the previous one is a prop away.
23
+ *
24
+ * From the small breakpoint the title and actions share a row only while both fit: the row
25
+ * wraps, so a toolbar wider than the space beside the title drops beneath it (as it does on a
26
+ * phone) rather than squeezing the title to nothing or running past the page edge. The title
27
+ * column's basis is its own min-content — the whole title, since the heading does not wrap — so
28
+ * the row breaks exactly when the full title and the actions no longer fit side by side.
29
+ */
30
+ export declare function PageHeader({ actions, aside, breadcrumbs, className, description, divider, eyebrow, metadata, title, ...props }: PageHeaderProps): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,18 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "../utils/cn.js";
4
- export function PageHeader({ actions, breadcrumbs, className, description, eyebrow, metadata, title, ...props }) {
5
- return (_jsxs("header", { className: cn("flex flex-col gap-4 border-b border-[color:var(--ssui-border)] pb-4 sm:flex-row sm:items-end sm:justify-between", className), ...props, children: [_jsxs("div", { className: "min-w-0 space-y-2", children: [breadcrumbs ? _jsx("div", { children: breadcrumbs }) : null, eyebrow ? (_jsx("div", { className: "text-xs font-medium uppercase text-[color:var(--ssui-text-muted)]", children: eyebrow })) : null, _jsxs("div", { className: "space-y-1", children: [_jsx("h1", { className: "truncate text-2xl font-semibold text-[color:var(--ssui-text)]", children: title }), description ? (_jsx("div", { className: "max-w-3xl text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null] }), metadata ? _jsx("div", { className: "flex flex-wrap gap-2", children: metadata }) : null] }), actions ? _jsx("div", { className: "flex shrink-0 flex-wrap gap-2", children: actions }) : null] }));
4
+ /**
5
+ * The page title in the shape a host draws its own main pages in: `text-xl` rising to
6
+ * `text-2xl` at the small breakpoint, no rule beneath unless asked for. A page built only
7
+ * from shared parts should be indistinguishable from one the host draws by hand, which is
8
+ * why the default is the host's shape and the previous one is a prop away.
9
+ *
10
+ * From the small breakpoint the title and actions share a row only while both fit: the row
11
+ * wraps, so a toolbar wider than the space beside the title drops beneath it (as it does on a
12
+ * phone) rather than squeezing the title to nothing or running past the page edge. The title
13
+ * column's basis is its own min-content — the whole title, since the heading does not wrap — so
14
+ * the row breaks exactly when the full title and the actions no longer fit side by side.
15
+ */
16
+ export function PageHeader({ actions, aside, breadcrumbs, className, description, divider = false, eyebrow, metadata, title, ...props }) {
17
+ return (_jsxs("header", { className: cn("flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-end sm:justify-between", divider && "border-b border-[color:var(--ssui-border)] pb-4", className), ...props, children: [_jsxs("div", { className: "min-w-0 flex-[1_1_min-content] space-y-2", children: [breadcrumbs ? _jsx("div", { children: breadcrumbs }) : null, eyebrow ? (_jsx("div", { className: "text-xs font-medium uppercase text-[color:var(--ssui-text-muted)]", children: eyebrow })) : null, _jsxs("div", { className: "space-y-1", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-3", children: [_jsx("h1", { className: "min-w-0 truncate text-xl font-semibold text-[color:var(--ssui-text)] sm:text-2xl", children: title }), aside ? (_jsx("div", { className: "ml-auto shrink-0", "data-slot": "aside", children: aside })) : null] }), description ? (_jsx("div", { className: "max-w-3xl text-sm text-[color:var(--ssui-text-muted)]", children: description })) : null] }), metadata ? _jsx("div", { className: "flex flex-wrap gap-2", children: metadata }) : null] }), actions ? _jsx("div", { className: "flex max-w-full shrink-0 flex-wrap gap-2", children: actions }) : null] }));
6
18
  }
@@ -0,0 +1,20 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The tones are roles, not colors: what the pill refers to. `reference` is a link to a record
4
+ * (a person's name), `tag` a label attached to one, `attribute` a field, `date` a point in
5
+ * time, `category` a grouping tag. Each reads its own `--ssui-pill-<tone>-bg` / `-text` pair.
6
+ */
7
+ export type PillTone = "reference" | "tag" | "attribute" | "date" | "category";
8
+ export interface PillProps extends React.HTMLAttributes<HTMLSpanElement> {
9
+ /**
10
+ * Render the consumer's own element (a router link, a button) with the pill's presentation.
11
+ * The pill carries no `href` or `onClick` of its own: the consumer's element does.
12
+ */
13
+ asChild?: boolean;
14
+ tone?: PillTone;
15
+ }
16
+ /**
17
+ * An inline reference chip. Sized to sit in a line of text (`align-baseline`), which is where
18
+ * a reference lives; for a status, use `StatusPill`, whose capitalization and dot say "state".
19
+ */
20
+ export declare const Pill: React.ForwardRefExoticComponent<PillProps & React.RefAttributes<HTMLSpanElement>>;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Slot } from "@radix-ui/react-slot";
3
+ import * as React from "react";
4
+ import { cn } from "../utils/cn.js";
5
+ const toneClasses = {
6
+ // A reference reaches the record it names, so it carries a hairline border: that, and the
7
+ // border strengthening under a pointer when the consumer has made it a link, is what
8
+ // separates it from a chip that is only a label.
9
+ reference: "border border-[color:var(--ssui-border)] bg-[color:var(--ssui-pill-reference-bg)] text-[color:var(--ssui-pill-reference-text)] [&:is(a,button)]:hover:border-[color:var(--ssui-border-strong)]",
10
+ tag: "bg-[color:var(--ssui-pill-tag-bg)] text-[color:var(--ssui-pill-tag-text)]",
11
+ attribute: "bg-[color:var(--ssui-pill-attribute-bg)] text-[color:var(--ssui-pill-attribute-text)]",
12
+ date: "bg-[color:var(--ssui-pill-date-bg)] text-[color:var(--ssui-pill-date-text)]",
13
+ category: "bg-[color:var(--ssui-pill-category-bg)] text-[color:var(--ssui-pill-category-text)]"
14
+ };
15
+ /**
16
+ * An inline reference chip. Sized to sit in a line of text (`align-baseline`), which is where
17
+ * a reference lives; for a status, use `StatusPill`, whose capitalization and dot say "state".
18
+ */
19
+ export const Pill = React.forwardRef(({ asChild = false, className, tone = "reference", ...props }, ref) => {
20
+ const Comp = asChild ? Slot : "span";
21
+ return (_jsx(Comp, { ref: ref, className: cn("inline-flex items-center gap-1 whitespace-nowrap rounded-[var(--ssui-radius)] px-1.5 py-0.5 align-baseline text-xs font-medium leading-tight transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--ssui-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[color:var(--ssui-bg)]", toneClasses[tone], className), "data-tone": tone, ...props }));
22
+ });
23
+ Pill.displayName = "Pill";
@@ -63,6 +63,20 @@
63
63
  --ssui-avatar-4-text: #9d174d;
64
64
  --ssui-avatar-5-bg: #f0f9ff;
65
65
  --ssui-avatar-5-text: #075985;
66
+ /* Pill tones: a reference (a link to a record), a tag, an attribute (a field), a date, and
67
+ a category (a grouping tag). Each defaults to a DERIVATION of the base contract rather
68
+ than a literal, so a consumer that has mapped the status and accent ramps sees a
69
+ coherent pair in both of its themes before it maps a single pill token. */
70
+ --ssui-pill-reference-bg: var(--ssui-accent-subtle);
71
+ --ssui-pill-reference-text: var(--ssui-accent-subtle-foreground);
72
+ --ssui-pill-tag-bg: var(--ssui-status-info-bg);
73
+ --ssui-pill-tag-text: var(--ssui-status-info-text);
74
+ --ssui-pill-attribute-bg: var(--ssui-status-neutral-bg);
75
+ --ssui-pill-attribute-text: var(--ssui-status-neutral-text);
76
+ --ssui-pill-date-bg: var(--ssui-status-warning-bg);
77
+ --ssui-pill-date-text: var(--ssui-status-warning-text);
78
+ --ssui-pill-category-bg: var(--ssui-status-success-bg);
79
+ --ssui-pill-category-text: var(--ssui-status-success-text);
66
80
  }
67
81
 
68
82
  @media (prefers-reduced-motion: reduce) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardreach/saas-ui",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Brand-neutral React UI primitives and SaaS app patterns for ForwardReach-owned business applications.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -73,7 +73,7 @@
73
73
  "typescript": "^5.8.3",
74
74
  "vitest": "^3.2.4"
75
75
  },
76
- "gitHead": "7517a542f48380e40fb8d7632f12ce352151487d",
76
+ "gitHead": "519fa3eab663bd95bba8a6dae8ac056538d25431",
77
77
  "scripts": {
78
78
  "build": "pnpm clean && tsc -p tsconfig.build.json && node scripts/copy-styles.mjs",
79
79
  "dev": "node scripts/copy-styles.mjs && tsc -p tsconfig.build.json --watch",