@epicbagel/saas-kit 1.34.0 → 1.36.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/README.md CHANGED
@@ -514,6 +514,70 @@ export function Appearance() {
514
514
  `paletteDescription`. Each palette's card is named by its `label` and
515
515
  described by its `description`.
516
516
 
517
+ ## Cover banner (1.35.0)
518
+
519
+ `primitives/cover-banner` is a stock cover: the banner the kit draws for
520
+ anything that has no cover image of its own, so a card without one is as tall
521
+ as its neighbours and doesn't look bare. It is the default banner a profile
522
+ shows before anyone uploads one, in whichever palette the app is in.
523
+
524
+ ```tsx
525
+ import { AspectRatio } from "@epicbagel/saas-kit/primitives/aspect-ratio";
526
+ import { CoverBanner } from "@epicbagel/saas-kit/primitives/cover-banner";
527
+
528
+ <AspectRatio ratio={4}>
529
+ {person.coverUrl ? (
530
+ <img src={person.coverUrl} alt="" className="size-full object-cover" />
531
+ ) : (
532
+ <CoverBanner seed={person.id} />
533
+ )}
534
+ </AspectRatio>
535
+ ```
536
+
537
+ - **Paired with the theme.** Every colour is mixed in CSS from the palette's
538
+ tokens: `primary` into `muted` for the tints, and into `foreground` for a
539
+ faint pattern. So a banner follows the palette, light and dark, and an
540
+ `AccentTheme` around it, with no image, no request and no canvas.
541
+ A black-and-white palette gets a black-and-white banner. Where the browser
542
+ can read a colour's channels, a neighbouring hue is mixed in beside the
543
+ primary's, turned away from yellow-green, where a tint goes muddy; elsewhere
544
+ a banner is one hue.
545
+ - **Six designs** (`COVER_BANNER_VARIANTS`): `wash`, one soft diagonal
546
+ gradient; `glow`, two soft lights; `grid`, a wash under a faint ruled grid;
547
+ `dots`, a wash under a faint dot matrix; `rings`, faint contour lines from
548
+ one corner; `arcs`, broad tonal bands round one corner. Each keeps its
549
+ deeper colour and its pattern on the far side, and its calmest part low on
550
+ the near side, where an avatar usually overlaps it.
551
+ - **A seed picks one.** `seed` is a string or a number, such as a person's id
552
+ or name, and picks the same design every time, on the server and in the
553
+ browser, with no randomness: one person keeps one banner, and a list of
554
+ people isn't a wall of the same one. It is read exactly as given, so pass
555
+ something that stays put, an id before a name. `coverBannerVariant(seed)`
556
+ is the same pick as a function. With no seed it is the first design.
557
+ - **Or name one.** `variant` names the design outright, and wins over `seed`.
558
+ - **It fills its frame.** Put it where the cover image would go, in a frame
559
+ you own, at any size: it is 100% of the frame's width and height. With no
560
+ frame, or one that gives no height, it holds 4:1. `className` and the other
561
+ `div` attributes go to the element, and a utility class wins over its rules.
562
+ - **Quiet.** The deepest tint is still four parts surface, so a face and a
563
+ name stay the loudest things on the card. Give an avatar that overlaps its
564
+ edge a ring in the card's colour (`ring-4 ring-card`), as you would over a
565
+ photograph.
566
+ - **Decoration only.** It is hidden from screen readers and takes no
567
+ children. It is not a placeholder while a real cover loads (that is
568
+ `Skeleton`), not a stand-in for a photo or a logo, and not a surface to set
569
+ text on: its colours promise no contrast to anything laid over it.
570
+ - **Why a banner, when `PersonAvatar` draws no generated tile.** A coloured
571
+ tile masks a missing photo by pretending to be one, so a person with no
572
+ photo gets plain initials. A cover is decoration and carries no identity, so
573
+ a stock one misleads nobody: it stands in for no particular picture, and the
574
+ seed only keeps a list from repeating itself.
575
+ - **Styles.** Its rules are in `styles/utilities.css`
576
+ (`components/cover-banner.css`), on `data-slot="cover-banner"` and each
577
+ design's `data-variant`, so an app that already imports the kit's styles
578
+ needs nothing more. In forced colours it is the plain canvas, with no
579
+ pattern. On paper browsers leave backgrounds out, so it prints blank.
580
+
517
581
  ## Branded page (1.34.0)
518
582
 
519
583
  `primitives/branded-page` is the whole of a public page one company shows to
@@ -2,9 +2,6 @@
2
2
  import {
3
3
  Button
4
4
  } from "./chunk-W326GKML.js";
5
- import {
6
- cn
7
- } from "./chunk-G6SRSWXB.js";
8
5
  import {
9
6
  useCopyToClipboard
10
7
  } from "./chunk-ZWZAAGQF.js";
@@ -13,6 +10,15 @@ import {
13
10
  import { forwardRef } from "react";
14
11
  import { Check, Copy } from "lucide-react";
15
12
  import { jsx, jsxs } from "react/jsx-runtime";
13
+ var ICON_SIZES = /* @__PURE__ */ new Set([
14
+ "icon",
15
+ "icon-lg",
16
+ "icon-sm",
17
+ "icon-md",
18
+ "icon-xs",
19
+ "icon-compact",
20
+ "icon-micro"
21
+ ]);
16
22
  var CopyButton = forwardRef(
17
23
  function CopyButton2({
18
24
  text,
@@ -27,7 +33,7 @@ var CopyButton = forwardRef(
27
33
  }, ref) {
28
34
  const { copied, copy } = useCopyToClipboard({ resetAfter });
29
35
  const iconOnly = label == null;
30
- const resolvedSize = size ?? (iconOnly ? "icon" : "sm");
36
+ const resolvedSize = iconOnly ? size != null && ICON_SIZES.has(size) ? size : "icon-compact" : size ?? "sm";
31
37
  return /* @__PURE__ */ jsxs(
32
38
  Button,
33
39
  {
@@ -35,7 +41,7 @@ var CopyButton = forwardRef(
35
41
  type: "button",
36
42
  variant,
37
43
  size: resolvedSize,
38
- className: cn(iconOnly ? "h-7 w-7" : void 0, className),
44
+ className,
39
45
  onClick: async (e) => {
40
46
  e.stopPropagation();
41
47
  const ok = await copy(text);
@@ -44,7 +50,7 @@ var CopyButton = forwardRef(
44
50
  "aria-label": iconOnly ? "Copy" : void 0,
45
51
  ...rest,
46
52
  children: [
47
- copied ? /* @__PURE__ */ jsx(Check, { className: cn("h-4 w-4", !iconOnly && "mr-1.5") }) : /* @__PURE__ */ jsx(Copy, { className: cn("h-4 w-4", !iconOnly && "mr-1.5") }),
53
+ copied ? /* @__PURE__ */ jsx(Check, {}) : /* @__PURE__ */ jsx(Copy, {}),
48
54
  !iconOnly && (copied ? copiedLabel ?? label : label)
49
55
  ]
50
56
  }
@@ -4,15 +4,21 @@
4
4
  var _chunkJDEXZGIYcjs = require('./chunk-JDEXZGIY.cjs');
5
5
 
6
6
 
7
- var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
8
-
9
-
10
7
  var _chunk4LXN6GLZcjs = require('./chunk-4LXN6GLZ.cjs');
11
8
 
12
9
  // src/primitives/copy-button.tsx
13
10
  var _react = require('react');
14
11
  var _lucidereact = require('lucide-react');
15
12
  var _jsxruntime = require('react/jsx-runtime');
13
+ var ICON_SIZES = /* @__PURE__ */ new Set([
14
+ "icon",
15
+ "icon-lg",
16
+ "icon-sm",
17
+ "icon-md",
18
+ "icon-xs",
19
+ "icon-compact",
20
+ "icon-micro"
21
+ ]);
16
22
  var CopyButton = _react.forwardRef.call(void 0,
17
23
  function CopyButton2({
18
24
  text,
@@ -27,7 +33,7 @@ var CopyButton = _react.forwardRef.call(void 0,
27
33
  }, ref) {
28
34
  const { copied, copy } = _chunk4LXN6GLZcjs.useCopyToClipboard.call(void 0, { resetAfter });
29
35
  const iconOnly = label == null;
30
- const resolvedSize = _nullishCoalesce(size, () => ( (iconOnly ? "icon" : "sm")));
36
+ const resolvedSize = iconOnly ? size != null && ICON_SIZES.has(size) ? size : "icon-compact" : _nullishCoalesce(size, () => ( "sm"));
31
37
  return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0,
32
38
  _chunkJDEXZGIYcjs.Button,
33
39
  {
@@ -35,7 +41,7 @@ var CopyButton = _react.forwardRef.call(void 0,
35
41
  type: "button",
36
42
  variant,
37
43
  size: resolvedSize,
38
- className: _chunkKWLSBTB2cjs.cn.call(void 0, iconOnly ? "h-7 w-7" : void 0, className),
44
+ className,
39
45
  onClick: async (e) => {
40
46
  e.stopPropagation();
41
47
  const ok = await copy(text);
@@ -44,7 +50,7 @@ var CopyButton = _react.forwardRef.call(void 0,
44
50
  "aria-label": iconOnly ? "Copy" : void 0,
45
51
  ...rest,
46
52
  children: [
47
- copied ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Check, { className: _chunkKWLSBTB2cjs.cn.call(void 0, "h-4 w-4", !iconOnly && "mr-1.5") }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Copy, { className: _chunkKWLSBTB2cjs.cn.call(void 0, "h-4 w-4", !iconOnly && "mr-1.5") }),
53
+ copied ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Check, {}) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Copy, {}),
48
54
  !iconOnly && (copied ? _nullishCoalesce(copiedLabel, () => ( label)) : label)
49
55
  ]
50
56
  }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  CopyButton
4
- } from "./chunk-DILYV4FI.js";
4
+ } from "./chunk-AL3FBR36.js";
5
5
  import {
6
6
  cn
7
7
  } from "./chunk-G6SRSWXB.js";
@@ -1,7 +1,7 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});"use client";
2
2
 
3
3
 
4
- var _chunkFARCLWLUcjs = require('./chunk-FARCLWLU.cjs');
4
+ var _chunkAYH5TTLDcjs = require('./chunk-AYH5TTLD.cjs');
5
5
 
6
6
 
7
7
  var _chunkKWLSBTB2cjs = require('./chunk-KWLSBTB2.cjs');
@@ -38,7 +38,7 @@ function CodeBlock({
38
38
  }
39
39
  ),
40
40
  copy ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
41
- _chunkFARCLWLUcjs.CopyButton,
41
+ _chunkAYH5TTLDcjs.CopyButton,
42
42
  {
43
43
  text: copyText,
44
44
  label: copyLabel,
@@ -35,15 +35,15 @@ var _chunkB765DSRGcjs = require('../chunk-B765DSRG.cjs');
35
35
 
36
36
 
37
37
 
38
+ var _chunk7LDJAXRXcjs = require('../chunk-7LDJAXRX.cjs');
39
+ require('../chunk-2HV2KKOE.cjs');
38
40
 
39
41
 
40
- var _chunkDHXKCMKYcjs = require('../chunk-DHXKCMKY.cjs');
41
42
 
42
43
 
43
44
 
44
45
 
45
- var _chunk7LDJAXRXcjs = require('../chunk-7LDJAXRX.cjs');
46
- require('../chunk-2HV2KKOE.cjs');
46
+ var _chunkDHXKCMKYcjs = require('../chunk-DHXKCMKY.cjs');
47
47
 
48
48
 
49
49
 
@@ -31,6 +31,12 @@ import {
31
31
  Marker,
32
32
  MarkerContent
33
33
  } from "../chunk-IW4ZIXXG.js";
34
+ import {
35
+ InputGroup,
36
+ InputGroupAddon,
37
+ InputGroupTextarea
38
+ } from "../chunk-QTPPNK6F.js";
39
+ import "../chunk-6FC65AZQ.js";
34
40
  import {
35
41
  Item,
36
42
  ItemContent,
@@ -38,12 +44,6 @@ import {
38
44
  ItemMedia,
39
45
  ItemTitle
40
46
  } from "../chunk-SG4ZE2EN.js";
41
- import {
42
- InputGroup,
43
- InputGroupAddon,
44
- InputGroupTextarea
45
- } from "../chunk-QTPPNK6F.js";
46
- import "../chunk-6FC65AZQ.js";
47
47
  import {
48
48
  FileDropArea,
49
49
  FileDropzone
@@ -1,11 +1,11 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});"use client";
2
2
 
3
3
 
4
- var _chunkBGCNGYPLcjs = require('../chunk-BGCNGYPL.cjs');
5
- require('../chunk-FARCLWLU.cjs');
4
+ var _chunkI6IFBKCLcjs = require('../chunk-I6IFBKCL.cjs');
5
+ require('../chunk-AYH5TTLD.cjs');
6
6
  require('../chunk-JDEXZGIY.cjs');
7
7
  require('../chunk-KWLSBTB2.cjs');
8
8
  require('../chunk-4LXN6GLZ.cjs');
9
9
 
10
10
 
11
- exports.CodeBlock = _chunkBGCNGYPLcjs.CodeBlock;
11
+ exports.CodeBlock = _chunkI6IFBKCLcjs.CodeBlock;
@@ -1,8 +1,8 @@
1
1
  "use client";
2
2
  import {
3
3
  CodeBlock
4
- } from "../chunk-HTPPNW74.js";
5
- import "../chunk-DILYV4FI.js";
4
+ } from "../chunk-DMX7LQ6D.js";
5
+ import "../chunk-AL3FBR36.js";
6
6
  import "../chunk-W326GKML.js";
7
7
  import "../chunk-G6SRSWXB.js";
8
8
  import "../chunk-ZWZAAGQF.js";
@@ -1,10 +1,10 @@
1
1
  "use strict";Object.defineProperty(exports, "__esModule", {value: true});"use client";
2
2
 
3
3
 
4
- var _chunkFARCLWLUcjs = require('../chunk-FARCLWLU.cjs');
4
+ var _chunkAYH5TTLDcjs = require('../chunk-AYH5TTLD.cjs');
5
5
  require('../chunk-JDEXZGIY.cjs');
6
6
  require('../chunk-KWLSBTB2.cjs');
7
7
  require('../chunk-4LXN6GLZ.cjs');
8
8
 
9
9
 
10
- exports.CopyButton = _chunkFARCLWLUcjs.CopyButton;
10
+ exports.CopyButton = _chunkAYH5TTLDcjs.CopyButton;
@@ -9,6 +9,15 @@ interface CopyButtonProps extends Omit<ButtonProps, "onClick" | "children"> {
9
9
  text: string;
10
10
  /** Optional label shown next to the icon. Omit for an icon-only button. */
11
11
  label?: ReactNode;
12
+ /**
13
+ * The Button size. With a `label` it is a text button, `sm` by default.
14
+ * With no label it is an icon button: `icon-compact` (28px) by default,
15
+ * and any of the Button's icon sizes as given, so beside other icon
16
+ * buttons it takes theirs (`size="icon"`) and shares their centre line. A
17
+ * text size on a button with no text means nothing, and is read as the
18
+ * default.
19
+ */
20
+ size?: ButtonProps["size"];
12
21
  /** Label shown after a successful copy. Defaults to `label`. */
13
22
  copiedLabel?: ReactNode;
14
23
  /** ms before the success state reverts. Defaults to 2000. */
@@ -9,6 +9,15 @@ interface CopyButtonProps extends Omit<ButtonProps, "onClick" | "children"> {
9
9
  text: string;
10
10
  /** Optional label shown next to the icon. Omit for an icon-only button. */
11
11
  label?: ReactNode;
12
+ /**
13
+ * The Button size. With a `label` it is a text button, `sm` by default.
14
+ * With no label it is an icon button: `icon-compact` (28px) by default,
15
+ * and any of the Button's icon sizes as given, so beside other icon
16
+ * buttons it takes theirs (`size="icon"`) and shares their centre line. A
17
+ * text size on a button with no text means nothing, and is read as the
18
+ * default.
19
+ */
20
+ size?: ButtonProps["size"];
12
21
  /** Label shown after a successful copy. Defaults to `label`. */
13
22
  copiedLabel?: ReactNode;
14
23
  /** ms before the success state reverts. Defaults to 2000. */
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  CopyButton
4
- } from "../chunk-DILYV4FI.js";
4
+ } from "../chunk-AL3FBR36.js";
5
5
  import "../chunk-W326GKML.js";
6
6
  import "../chunk-G6SRSWXB.js";
7
7
  import "../chunk-ZWZAAGQF.js";
@@ -0,0 +1,41 @@
1
+ "use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } }"use client";
2
+
3
+ // src/primitives/cover-banner.tsx
4
+ var _react = require('react');
5
+ var _jsxruntime = require('react/jsx-runtime');
6
+ var COVER_BANNER_VARIANTS = ["wash", "glow", "grid", "dots", "rings", "arcs"];
7
+ function hashSeed(seed) {
8
+ let hash = 2166136261;
9
+ for (let index = 0; index < seed.length; index++) {
10
+ hash ^= seed.charCodeAt(index);
11
+ hash = Math.imul(hash, 16777619);
12
+ }
13
+ hash ^= hash >>> 16;
14
+ hash = Math.imul(hash, 2246822507);
15
+ hash ^= hash >>> 13;
16
+ hash = Math.imul(hash, 3266489909);
17
+ hash ^= hash >>> 16;
18
+ return hash >>> 0;
19
+ }
20
+ function coverBannerVariant(seed) {
21
+ const text = seed == null ? "" : String(seed);
22
+ if (text === "") return COVER_BANNER_VARIANTS[0];
23
+ return COVER_BANNER_VARIANTS[hashSeed(text) % COVER_BANNER_VARIANTS.length];
24
+ }
25
+ var CoverBanner = _react.forwardRef.call(void 0, function CoverBanner2({ seed, variant, ...props }, ref) {
26
+ return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
27
+ "div",
28
+ {
29
+ ref,
30
+ ...props,
31
+ "aria-hidden": "true",
32
+ "data-slot": "cover-banner",
33
+ "data-variant": _nullishCoalesce(variant, () => ( coverBannerVariant(seed)))
34
+ }
35
+ );
36
+ });
37
+
38
+
39
+
40
+
41
+ exports.COVER_BANNER_VARIANTS = COVER_BANNER_VARIANTS; exports.CoverBanner = CoverBanner; exports.coverBannerVariant = coverBannerVariant;
@@ -0,0 +1,67 @@
1
+ import * as React from 'react';
2
+ import { HTMLAttributes } from 'react';
3
+
4
+ /**
5
+ * The stock designs, in the order a seed deals them:
6
+ *
7
+ * - `wash`: one soft diagonal gradient.
8
+ * - `glow`: two soft lights, one low on the near side and one high on the far.
9
+ * - `grid`: a wash under a faint ruled grid.
10
+ * - `dots`: a wash under a faint dot matrix.
11
+ * - `rings`: faint contour lines spreading from the far lower corner.
12
+ * - `arcs`: broad tonal bands curving round the far lower corner.
13
+ *
14
+ * The order and the number are part of the contract: a seed picks by
15
+ * position, so moving, removing or adding a design would deal everyone a
16
+ * different banner.
17
+ */
18
+ declare const COVER_BANNER_VARIANTS: readonly ["wash", "glow", "grid", "dots", "rings", "arcs"];
19
+ /** One of the stock designs, by name. */
20
+ type CoverBannerVariant = (typeof COVER_BANNER_VARIANTS)[number];
21
+ /**
22
+ * The design a seed picks: the same one every time, on the server and in the
23
+ * browser, with no randomness. The seed is read exactly as given (case and
24
+ * spaces count), so pass something that stays put: an id before a name. A
25
+ * number is read as its digits. No seed (`null`, `undefined` or `""`) is the
26
+ * first design.
27
+ */
28
+ declare function coverBannerVariant(seed: string | number | null | undefined): CoverBannerVariant;
29
+ interface CoverBannerProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
30
+ /**
31
+ * Picks one of the stock designs, the same one every time for the same
32
+ * seed: a person's id, or their name. One person keeps one banner, and a
33
+ * list of people isn't a wall of the same one.
34
+ */
35
+ seed?: string | number | null;
36
+ /** Names the design outright. Wins over `seed`. */
37
+ variant?: CoverBannerVariant;
38
+ }
39
+ /**
40
+ * A stock cover: the banner the kit draws for anything that has no cover
41
+ * image of its own, so a card without one is the same height as its
42
+ * neighbours and doesn't look bare.
43
+ *
44
+ * It is drawn in CSS from the palette's own tokens (the primary colour mixed
45
+ * into the muted surface, and into the foreground for a faint pattern), so it
46
+ * follows the palette, light and dark, and an `AccentTheme` around it, with
47
+ * no image, no request and no canvas. It fills the frame it is put in; with
48
+ * no frame, or one that gives no height, it holds 4:1. Put it where the
49
+ * cover image would go and it sits quietly behind an avatar and a name.
50
+ *
51
+ * `PersonAvatar` deliberately draws no generated tile, because a coloured
52
+ * tile masks a missing photo by pretending to be one. A banner is different:
53
+ * a cover is decoration and carries no identity, so a stock one misleads
54
+ * nobody. It stands in for no particular picture, and the seed only keeps a
55
+ * list from repeating itself: it says nothing about the person.
56
+ *
57
+ * It is decoration: hidden from screen readers, with no children. It is not
58
+ * a placeholder while a real cover loads (that is `Skeleton`), not a
59
+ * stand-in for a photo or a logo, and not a surface to set text on: its
60
+ * colours promise no contrast to anything laid over it. An avatar that
61
+ * overlaps its edge wants a ring in the card's colour (`ring-4 ring-card`).
62
+ *
63
+ * Its styles are the kit's `styles/utilities.css` (`components/cover-banner.css`).
64
+ */
65
+ declare const CoverBanner: React.ForwardRefExoticComponent<CoverBannerProps & React.RefAttributes<HTMLDivElement>>;
66
+
67
+ export { COVER_BANNER_VARIANTS, CoverBanner, type CoverBannerProps, type CoverBannerVariant, coverBannerVariant };
@@ -0,0 +1,67 @@
1
+ import * as React from 'react';
2
+ import { HTMLAttributes } from 'react';
3
+
4
+ /**
5
+ * The stock designs, in the order a seed deals them:
6
+ *
7
+ * - `wash`: one soft diagonal gradient.
8
+ * - `glow`: two soft lights, one low on the near side and one high on the far.
9
+ * - `grid`: a wash under a faint ruled grid.
10
+ * - `dots`: a wash under a faint dot matrix.
11
+ * - `rings`: faint contour lines spreading from the far lower corner.
12
+ * - `arcs`: broad tonal bands curving round the far lower corner.
13
+ *
14
+ * The order and the number are part of the contract: a seed picks by
15
+ * position, so moving, removing or adding a design would deal everyone a
16
+ * different banner.
17
+ */
18
+ declare const COVER_BANNER_VARIANTS: readonly ["wash", "glow", "grid", "dots", "rings", "arcs"];
19
+ /** One of the stock designs, by name. */
20
+ type CoverBannerVariant = (typeof COVER_BANNER_VARIANTS)[number];
21
+ /**
22
+ * The design a seed picks: the same one every time, on the server and in the
23
+ * browser, with no randomness. The seed is read exactly as given (case and
24
+ * spaces count), so pass something that stays put: an id before a name. A
25
+ * number is read as its digits. No seed (`null`, `undefined` or `""`) is the
26
+ * first design.
27
+ */
28
+ declare function coverBannerVariant(seed: string | number | null | undefined): CoverBannerVariant;
29
+ interface CoverBannerProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
30
+ /**
31
+ * Picks one of the stock designs, the same one every time for the same
32
+ * seed: a person's id, or their name. One person keeps one banner, and a
33
+ * list of people isn't a wall of the same one.
34
+ */
35
+ seed?: string | number | null;
36
+ /** Names the design outright. Wins over `seed`. */
37
+ variant?: CoverBannerVariant;
38
+ }
39
+ /**
40
+ * A stock cover: the banner the kit draws for anything that has no cover
41
+ * image of its own, so a card without one is the same height as its
42
+ * neighbours and doesn't look bare.
43
+ *
44
+ * It is drawn in CSS from the palette's own tokens (the primary colour mixed
45
+ * into the muted surface, and into the foreground for a faint pattern), so it
46
+ * follows the palette, light and dark, and an `AccentTheme` around it, with
47
+ * no image, no request and no canvas. It fills the frame it is put in; with
48
+ * no frame, or one that gives no height, it holds 4:1. Put it where the
49
+ * cover image would go and it sits quietly behind an avatar and a name.
50
+ *
51
+ * `PersonAvatar` deliberately draws no generated tile, because a coloured
52
+ * tile masks a missing photo by pretending to be one. A banner is different:
53
+ * a cover is decoration and carries no identity, so a stock one misleads
54
+ * nobody. It stands in for no particular picture, and the seed only keeps a
55
+ * list from repeating itself: it says nothing about the person.
56
+ *
57
+ * It is decoration: hidden from screen readers, with no children. It is not
58
+ * a placeholder while a real cover loads (that is `Skeleton`), not a
59
+ * stand-in for a photo or a logo, and not a surface to set text on: its
60
+ * colours promise no contrast to anything laid over it. An avatar that
61
+ * overlaps its edge wants a ring in the card's colour (`ring-4 ring-card`).
62
+ *
63
+ * Its styles are the kit's `styles/utilities.css` (`components/cover-banner.css`).
64
+ */
65
+ declare const CoverBanner: React.ForwardRefExoticComponent<CoverBannerProps & React.RefAttributes<HTMLDivElement>>;
66
+
67
+ export { COVER_BANNER_VARIANTS, CoverBanner, type CoverBannerProps, type CoverBannerVariant, coverBannerVariant };
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ // src/primitives/cover-banner.tsx
4
+ import { forwardRef } from "react";
5
+ import { jsx } from "react/jsx-runtime";
6
+ var COVER_BANNER_VARIANTS = ["wash", "glow", "grid", "dots", "rings", "arcs"];
7
+ function hashSeed(seed) {
8
+ let hash = 2166136261;
9
+ for (let index = 0; index < seed.length; index++) {
10
+ hash ^= seed.charCodeAt(index);
11
+ hash = Math.imul(hash, 16777619);
12
+ }
13
+ hash ^= hash >>> 16;
14
+ hash = Math.imul(hash, 2246822507);
15
+ hash ^= hash >>> 13;
16
+ hash = Math.imul(hash, 3266489909);
17
+ hash ^= hash >>> 16;
18
+ return hash >>> 0;
19
+ }
20
+ function coverBannerVariant(seed) {
21
+ const text = seed == null ? "" : String(seed);
22
+ if (text === "") return COVER_BANNER_VARIANTS[0];
23
+ return COVER_BANNER_VARIANTS[hashSeed(text) % COVER_BANNER_VARIANTS.length];
24
+ }
25
+ var CoverBanner = forwardRef(function CoverBanner2({ seed, variant, ...props }, ref) {
26
+ return /* @__PURE__ */ jsx(
27
+ "div",
28
+ {
29
+ ref,
30
+ ...props,
31
+ "aria-hidden": "true",
32
+ "data-slot": "cover-banner",
33
+ "data-variant": variant ?? coverBannerVariant(seed)
34
+ }
35
+ );
36
+ });
37
+ export {
38
+ COVER_BANNER_VARIANTS,
39
+ CoverBanner,
40
+ coverBannerVariant
41
+ };
@@ -17,8 +17,8 @@ require('../chunk-ELPV44S6.cjs');
17
17
  require('../chunk-2BHI5ZWJ.cjs');
18
18
 
19
19
 
20
- var _chunkBGCNGYPLcjs = require('../chunk-BGCNGYPL.cjs');
21
- require('../chunk-FARCLWLU.cjs');
20
+ var _chunkI6IFBKCLcjs = require('../chunk-I6IFBKCL.cjs');
21
+ require('../chunk-AYH5TTLD.cjs');
22
22
  require('../chunk-WDXEJDMO.cjs');
23
23
 
24
24
 
@@ -281,7 +281,7 @@ function FilePreviewBody({
281
281
  kind === "markdown" ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkKENBU3NVcjs.LazyMarkdown, { components: { a: NewTabLink, ...markdownComponents }, children: text.text }) : (
282
282
  // The theme's relaxed leading is a fixed 1.8rem, which spaces a
283
283
  // file's small monospace lines apart; read it at 20px lines.
284
- /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkBGCNGYPLcjs.CodeBlock, { wrap: true, className: "[&>pre]:leading-5", children: text.text })
284
+ /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _chunkI6IFBKCLcjs.CodeBlock, { wrap: true, className: "[&>pre]:leading-5", children: text.text })
285
285
  )
286
286
  ] });
287
287
  }
@@ -17,8 +17,8 @@ import "../chunk-INNGGLMZ.js";
17
17
  import "../chunk-4HWCNRK4.js";
18
18
  import {
19
19
  CodeBlock
20
- } from "../chunk-HTPPNW74.js";
21
- import "../chunk-DILYV4FI.js";
20
+ } from "../chunk-DMX7LQ6D.js";
21
+ import "../chunk-AL3FBR36.js";
22
22
  import "../chunk-3QT5VYZ3.js";
23
23
  import {
24
24
  Text
@@ -2,11 +2,11 @@
2
2
  "use client";
3
3
 
4
4
 
5
- var _chunkB54ZEKBGcjs = require('../chunk-B54ZEKBG.cjs');
6
- require('../chunk-DFB4ILN6.cjs');
5
+ var _chunkY6X6XDHEcjs = require('../chunk-Y6X6XDHE.cjs');
7
6
 
8
7
 
9
- var _chunkY6X6XDHEcjs = require('../chunk-Y6X6XDHE.cjs');
8
+ var _chunkB54ZEKBGcjs = require('../chunk-B54ZEKBG.cjs');
9
+ require('../chunk-DFB4ILN6.cjs');
10
10
 
11
11
 
12
12
  var _chunkKWLSBTB2cjs = require('../chunk-KWLSBTB2.cjs');
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  "use client";
3
+ import {
4
+ PageHeader
5
+ } from "../chunk-VWOWPNKT.js";
3
6
  import {
4
7
  PageTabs
5
8
  } from "../chunk-XH2I3YJ6.js";
6
9
  import "../chunk-7CRDYV3C.js";
7
- import {
8
- PageHeader
9
- } from "../chunk-VWOWPNKT.js";
10
10
  import {
11
11
  cn
12
12
  } from "../chunk-G6SRSWXB.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epicbagel/saas-kit",
3
- "version": "1.34.0",
3
+ "version": "1.36.0",
4
4
  "description": "Accessible React primitives, layout components and design tokens for SaaS apps, built on Radix UI and Tailwind CSS v4.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -490,6 +490,16 @@
490
490
  "default": "./dist/primitives/count-badge.cjs"
491
491
  }
492
492
  },
493
+ "./primitives/cover-banner": {
494
+ "import": {
495
+ "types": "./dist/primitives/cover-banner.d.ts",
496
+ "default": "./dist/primitives/cover-banner.js"
497
+ },
498
+ "require": {
499
+ "types": "./dist/primitives/cover-banner.d.cts",
500
+ "default": "./dist/primitives/cover-banner.cjs"
501
+ }
502
+ },
493
503
  "./primitives/cycling-prompt": {
494
504
  "import": {
495
505
  "types": "./dist/primitives/cycling-prompt.d.ts",
@@ -0,0 +1,201 @@
1
+ /* Cover banner — the presentation of primitives/cover-banner.tsx: a stock
2
+ * cover for anything with no cover image of its own. Every selector lands on
3
+ * the banner's data-slot, and each design on its data-variant.
4
+ *
5
+ * Every colour is mixed from the palette's tokens: the primary colour into
6
+ * the muted surface for the tints, and into the foreground for a pattern's
7
+ * ink. So a banner follows the palette, light and dark, and an AccentTheme
8
+ * around it, and nothing here names a mode, a palette or a colour. In the
9
+ * components layer, so a consumer's utility classes still win. Part of
10
+ * utilities.css. */
11
+ @layer components {
12
+ [data-slot="cover-banner"] {
13
+ /* The palette's colours, whole (the bare tokens are H S% L% triplets):
14
+ * the surface a banner is made of, the brand's colour that tints it, and
15
+ * a neighbour of that colour, which is the brand's own until the block
16
+ * below turns its hue. */
17
+ --color-cover-banner-surface: var(--color-muted);
18
+ --color-cover-banner-brand: var(--color-primary);
19
+ --color-cover-banner-near: var(--color-primary);
20
+ /* How far round the hue wheel the neighbour sits, in degrees at most.
21
+ * Each design sets its own, so two banners side by side differ in more
22
+ * than their shapes. */
23
+ --cover-banner-turn: 20;
24
+
25
+ /* The surface with the brand's colour in it, at four depths, and with
26
+ * its neighbour at two. The deepest is still four parts surface: a
27
+ * banner sits behind a face and a name. The surface is named first, so
28
+ * the fallback Tailwind writes for a browser without color-mix() is the
29
+ * plain surface. */
30
+ --color-cover-banner-1: color-mix(in oklab, var(--color-cover-banner-surface) 95%, var(--color-cover-banner-brand));
31
+ --color-cover-banner-2: color-mix(in oklab, var(--color-cover-banner-surface) 90%, var(--color-cover-banner-brand));
32
+ --color-cover-banner-3: color-mix(in oklab, var(--color-cover-banner-surface) 84%, var(--color-cover-banner-brand));
33
+ --color-cover-banner-4: color-mix(in oklab, var(--color-cover-banner-surface) 78%, var(--color-cover-banner-brand));
34
+ --color-cover-banner-near-2: color-mix(in oklab, var(--color-cover-banner-surface) 90%, var(--color-cover-banner-near));
35
+ --color-cover-banner-near-4: color-mix(in oklab, var(--color-cover-banner-surface) 78%, var(--color-cover-banner-near));
36
+ /* A pattern's ink: the text's colour and the brand's in equal parts,
37
+ * then mostly see-through. A rule covers more of the banner than a ring,
38
+ * and a ring more than a dot, so each is fainter than the next. */
39
+ --color-cover-banner-ink: color-mix(in oklab, var(--color-foreground) 50%, var(--color-cover-banner-brand));
40
+ --color-cover-banner-rule: color-mix(in oklab, transparent 91%, var(--color-cover-banner-ink));
41
+ --color-cover-banner-ring: color-mix(in oklab, transparent 86%, var(--color-cover-banner-ink));
42
+ --color-cover-banner-dot: color-mix(in oklab, transparent 78%, var(--color-cover-banner-ink));
43
+
44
+ position: relative;
45
+ display: block;
46
+ overflow: hidden;
47
+ /* Fills the frame it is put in. Where the frame gives no height, a
48
+ * percentage height is auto, and the ratio holds 4:1 instead. */
49
+ width: 100%;
50
+ height: 100%;
51
+ aspect-ratio: 4 / 1;
52
+ background-color: var(--color-cover-banner-1);
53
+ }
54
+
55
+ /* Where the browser can read a colour's channels, two refinements.
56
+ *
57
+ * A little more chroma, so a coloured brand tints the surface as clearly
58
+ * as a black one darkens it.
59
+ *
60
+ * And the neighbour's hue turned, by up to --cover-banner-turn degrees,
61
+ * always away from yellow-green (hue 110), where a tint goes muddy: a warm
62
+ * colour leans towards red, a cool one towards blue and on to pink, and a
63
+ * colour at 110 stays put. The sine is what sets the direction and eases
64
+ * the amount.
65
+ *
66
+ * A grey has no chroma to raise or turn, so a black-and-white palette
67
+ * stays black and white. Elsewhere a banner is the plain mixes above, in
68
+ * one hue. */
69
+ @supports (color: oklch(from red l calc(c * 1.3) calc(h + 20 * sin((h - 110) * 0.5deg)))) {
70
+ [data-slot="cover-banner"] {
71
+ --color-cover-banner-brand: oklch(from var(--color-primary) l calc(c * 1.3) h);
72
+ --color-cover-banner-near: oklch(
73
+ from var(--color-primary) l calc(c * 1.3) calc(h + var(--cover-banner-turn) * sin((h - 110) * 0.5deg))
74
+ );
75
+ }
76
+ }
77
+
78
+ /* A pattern is its own layer, so it can fade out across the banner. */
79
+ [data-slot="cover-banner"]::after {
80
+ position: absolute;
81
+ inset: 0;
82
+ pointer-events: none;
83
+ }
84
+
85
+ /* Each design keeps its deeper colour and its pattern on the far side, and
86
+ * its calmest part low on the near side, where an avatar overlaps it. */
87
+
88
+ /* One soft diagonal gradient, from the brand's colour to its neighbour. */
89
+ [data-slot="cover-banner"][data-variant="wash"] {
90
+ --cover-banner-turn: 20;
91
+ background-image: linear-gradient(
92
+ 112deg,
93
+ var(--color-cover-banner-1) 0%,
94
+ var(--color-cover-banner-2) 42%,
95
+ var(--color-cover-banner-near-4) 100%
96
+ );
97
+ }
98
+
99
+ /* Two soft lights: the brand's low on the near side, its neighbour's high
100
+ * on the far side. */
101
+ [data-slot="cover-banner"][data-variant="glow"] {
102
+ --cover-banner-turn: 34;
103
+ background-image:
104
+ radial-gradient(ellipse 62% 150% at 4% 122%, var(--color-cover-banner-4), transparent 72%),
105
+ radial-gradient(ellipse 58% 150% at 99% -26%, var(--color-cover-banner-near-4), transparent 72%);
106
+ }
107
+
108
+ /* A wash under a ruled grid, which fades out towards the near side. */
109
+ [data-slot="cover-banner"][data-variant="grid"] {
110
+ --cover-banner-turn: 18;
111
+ background-image: linear-gradient(
112
+ 100deg,
113
+ var(--color-cover-banner-1) 0%,
114
+ var(--color-cover-banner-2) 55%,
115
+ var(--color-cover-banner-near-4) 100%
116
+ );
117
+ }
118
+
119
+ [data-slot="cover-banner"][data-variant="grid"]::after {
120
+ content: "";
121
+ background-image:
122
+ linear-gradient(to right, var(--color-cover-banner-rule) 1px, transparent 1px),
123
+ linear-gradient(to bottom, var(--color-cover-banner-rule) 1px, transparent 1px);
124
+ background-size: 24px 24px;
125
+ background-position: center;
126
+ -webkit-mask-image: linear-gradient(100deg, transparent 8%, #000 100%);
127
+ mask-image: linear-gradient(100deg, transparent 8%, #000 100%);
128
+ }
129
+
130
+ /* A wash under a dot matrix, which fades out from the far upper corner. */
131
+ [data-slot="cover-banner"][data-variant="dots"] {
132
+ --cover-banner-turn: 28;
133
+ background-image: linear-gradient(
134
+ 200deg,
135
+ var(--color-cover-banner-near-4) 0%,
136
+ var(--color-cover-banner-2) 46%,
137
+ var(--color-cover-banner-1) 100%
138
+ );
139
+ }
140
+
141
+ [data-slot="cover-banner"][data-variant="dots"]::after {
142
+ content: "";
143
+ background-image: radial-gradient(circle, var(--color-cover-banner-dot) 1.1px, transparent 1.7px);
144
+ background-size: 14px 14px;
145
+ background-position: center;
146
+ -webkit-mask-image: radial-gradient(ellipse 86% 160% at 100% 0%, #000 8%, transparent 80%);
147
+ mask-image: radial-gradient(ellipse 86% 160% at 100% 0%, #000 8%, transparent 80%);
148
+ }
149
+
150
+ /* Contour lines spreading from under the far lower corner. */
151
+ [data-slot="cover-banner"][data-variant="rings"] {
152
+ --cover-banner-turn: 24;
153
+ background-image: linear-gradient(
154
+ 250deg,
155
+ var(--color-cover-banner-3) 0%,
156
+ var(--color-cover-banner-near-2) 40%,
157
+ var(--color-cover-banner-1) 100%
158
+ );
159
+ }
160
+
161
+ /* Each line eases in and out over three pixels, so it stays smooth where
162
+ * the browser draws a hard stop jagged. */
163
+ [data-slot="cover-banner"][data-variant="rings"]::after {
164
+ content: "";
165
+ background-image: repeating-radial-gradient(
166
+ circle at 84% 140%,
167
+ transparent 0,
168
+ transparent 18.5px,
169
+ var(--color-cover-banner-ring) 20px,
170
+ transparent 21.5px
171
+ );
172
+ -webkit-mask-image: radial-gradient(ellipse 84% 200% at 84% 140%, #000 10%, transparent 76%);
173
+ mask-image: radial-gradient(ellipse 84% 200% at 84% 140%, #000 10%, transparent 76%);
174
+ }
175
+
176
+ /* Broad tonal bands curving round the far lower corner, deepest inside. */
177
+ [data-slot="cover-banner"][data-variant="arcs"] {
178
+ --cover-banner-turn: 16;
179
+ background-image: radial-gradient(
180
+ circle at 100% 150%,
181
+ var(--color-cover-banner-near-4) 0%,
182
+ var(--color-cover-banner-near-4) 24%,
183
+ var(--color-cover-banner-3) 24.2%,
184
+ var(--color-cover-banner-3) 43%,
185
+ var(--color-cover-banner-2) 43.2%,
186
+ var(--color-cover-banner-2) 64%,
187
+ var(--color-cover-banner-1) 64.2%
188
+ );
189
+ }
190
+
191
+ /* Someone who asked for their own colours gets them, without a pattern. */
192
+ @media (forced-colors: active) {
193
+ [data-slot="cover-banner"] {
194
+ background: Canvas;
195
+ }
196
+
197
+ [data-slot="cover-banner"]::after {
198
+ content: none;
199
+ }
200
+ }
201
+ }
@@ -6,3 +6,4 @@
6
6
  @import "./components/message-scroller.css";
7
7
  @import "./components/frosted-surface.css";
8
8
  @import "./components/poster.css";
9
+ @import "./components/cover-banner.css";
@@ -1,10 +1,12 @@
1
1
  /* Palette-free utilities and component classes: shadcn's data-* variants,
2
2
  * no-scrollbar, scroll-fade and shimmer; the cn-attachment, cn-bubble,
3
3
  * cn-marker, cn-message and cn-message-scroller classes; the poster
4
- * family's data-slot rules; and the kit's keyframes. Defines no palette and no @source.
4
+ * family's and the cover banner's data-slot rules; and the kit's keyframes.
5
+ * Defines no palette and no @source.
5
6
  * The component classes @apply the semantic colours (bg-muted,
6
- * text-muted-foreground, ring-ring, …), so the host's @theme must define
7
- * them — tokens.css does. */
7
+ * text-muted-foreground, ring-ring, …), and the cover banner mixes its
8
+ * colours from them, so the host's @theme must define them — tokens.css
9
+ * does. */
8
10
  @import "./shadcn.css";
9
11
  @import "./components.css";
10
12
  @import "./keyframes.css";