@stonedogcode/style 0.13.0 → 0.15.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
@@ -12,7 +12,7 @@ the whole component set re-skins at runtime. No component here knows a colour.
12
12
 
13
13
  ## Status
14
14
 
15
- Early. The preset is complete (22 recipes, 43 colour tokens); the component set
15
+ Early. The preset is complete (23 recipes, 43 colour tokens); the component set
16
16
  is being extracted incrementally and currently covers the layout and typography
17
17
  primitives. See [CLAUDE.md](./CLAUDE.md) for the architecture and the
18
18
  contribution rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "StoneDogCode L.L.C.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import React from "react";
4
4
  import { buttonRecipe } from "styled-system/recipes";
5
- import { cx } from "styled-system/css";
5
+ import { css, cx } from "styled-system/css";
6
6
  import { useLinkComponent, useResolvedVariant } from "../config/style-config";
7
7
  import { ALL_VARIANTS } from "../config/types";
8
8
 
@@ -17,6 +17,15 @@ import { ALL_VARIANTS } from "../config/types";
17
17
  */
18
18
  const LINK_VARIANTS = ALL_VARIANTS;
19
19
 
20
+ /**
21
+ * How a link sits in its surroundings.
22
+ *
23
+ * `text` is the default and the safe one: a link in a sentence must not become
24
+ * a 48px control. `flow` is the layout participant. `control` is the tap
25
+ * target, and carries the house 48px floor.
26
+ */
27
+ export type LinkPresentation = "text" | "flow" | "control";
28
+
20
29
  export interface StyledLinkProps
21
30
  extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
22
31
  /** Where the link goes. */
@@ -55,6 +64,61 @@ export interface StyledLinkProps
55
64
  */
56
65
  externalIndicator?: React.ReactNode;
57
66
  variant?: string;
67
+ /**
68
+ * Render as a standalone control with a 48x48 tap target, rather than as
69
+ * text inside a sentence.
70
+ *
71
+ * **The default is inline, and that is deliberate rather than a shortcut.**
72
+ * `buttonRecipe`'s base states `min-height: 48px`, `display: inline-flex`
73
+ * and padding — correct for a control, and wrong for a link in a paragraph,
74
+ * where it forces a 48px line box and breaks the text flow. Measured at
75
+ * 48.375px before this prop existed.
76
+ *
77
+ * Inline is also what the standard expects: WCAG 2.5.5 and 2.5.8 both carve
78
+ * out targets that are "in a sentence or block of text", so a text link at
79
+ * text height is conformant. The floor applies to the standalone case, and
80
+ * `standalone` is how a nav item, a card action or a button-shaped link asks
81
+ * for it.
82
+ *
83
+ * @deprecated Use `presentation` instead — `standalone` maps to
84
+ * `presentation="control"`. It is kept because it is public API and consumers
85
+ * pass it today; it will be removed once they have moved.
86
+ */
87
+ standalone?: boolean;
88
+ /**
89
+ * How the link sits in its surroundings. Three cases, because there are
90
+ * genuinely three (NEH-728).
91
+ *
92
+ * | | display | min-height | for |
93
+ * |---|---|---|---|
94
+ * | `text` (default) | `inline` | none | a link inside a sentence |
95
+ * | `flow` | `inline-flex` | none | a link that is a layout participant |
96
+ * | `control` | `inline-flex` | **48px** | a nav item, a card action |
97
+ *
98
+ * ## Why `flow` had to exist
99
+ *
100
+ * `text` and `control` look like they cover the space, and they do not. A
101
+ * link that is neither prose nor a tap target is extremely common — a row in
102
+ * a list, a cell in a grid, anything given a width by its parent — and
103
+ * HopperGuard had 67 of them (NEH-728).
104
+ *
105
+ * Neither of the other two can express it, and **both fail silently**:
106
+ *
107
+ * - `control` adds the 48px floor to links that are not tap targets, which
108
+ * changes layout everywhere it is wrong.
109
+ * - `text` sets `display: inline`, and on a non-replaced inline box **`width`
110
+ * does not apply** and **`margin-left: auto` does nothing** — so a `w` prop
111
+ * becomes a no-op and a right-hand icon loses its push-to-end. No build
112
+ * error, no type error, no warning; the link just renders wrong.
113
+ *
114
+ * `flow` is inline-flex without the floor: it takes a width, it lays its
115
+ * icons out, and it does not claim to be a 48px target when it is not.
116
+ *
117
+ * **Do not reach for `flow` to escape the tap-target floor on something that
118
+ * IS a control.** The floor is a house minimum, not a default to be routed
119
+ * around; `control` is the honest answer for anything a finger aims at.
120
+ */
121
+ presentation?: LinkPresentation;
58
122
  }
59
123
 
60
124
  /** The default external-destination glyph — "↗", north-east arrow. */
@@ -83,6 +147,8 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
83
147
  rightIcon,
84
148
  externalIndicator,
85
149
  variant,
150
+ standalone = false,
151
+ presentation,
86
152
  className,
87
153
  ...rest
88
154
  },
@@ -90,7 +156,38 @@ export const StyledLink = React.forwardRef<HTMLAnchorElement, StyledLinkProps>(
90
156
  ) {
91
157
  const HostLink = useLinkComponent();
92
158
  const resolved = useResolvedVariant(variant ?? "link", LINK_VARIANTS);
93
- const classes = cx(buttonRecipe({ variant: resolved }), className);
159
+
160
+ /*
161
+ * `presentation` wins; `standalone` is the deprecated spelling of
162
+ * `control`. Resolved in one place so there is no call site where the two
163
+ * disagree and the answer depends on which branch is read first.
164
+ */
165
+ const mode: LinkPresentation =
166
+ presentation ?? (standalone ? "control" : "text");
167
+
168
+ // The variant still comes from `buttonRecipe`, so colour, underline and
169
+ // hover stay one definition shared with every other control. Only the BOX
170
+ // is overridden — the properties that make a control a control are exactly
171
+ // the ones that break a sentence.
172
+ const classes = cx(
173
+ buttonRecipe({ variant: resolved }),
174
+ mode === "control"
175
+ ? undefined
176
+ : css({
177
+ /*
178
+ * `text` goes fully inline so it sits in a line box like any other
179
+ * word. `flow` stays a flex container: it is a layout participant,
180
+ * and on a non-replaced inline box `width` does not apply and
181
+ * `margin-left: auto` does nothing — so an inline `flow` would
182
+ * silently drop both (NEH-728).
183
+ */
184
+ display: mode === "flow" ? "inline-flex" : "inline",
185
+ minHeight: "0",
186
+ minWidth: "0",
187
+ padding: "0",
188
+ }),
189
+ className,
190
+ );
94
191
 
95
192
  const indicator =
96
193
  externalIndicator === undefined ? EXTERNAL_GLYPH : externalIndicator;
@@ -2,10 +2,51 @@
2
2
 
3
3
  import React from "react";
4
4
  import { css, cx } from "styled-system/css";
5
+ import { tagRecipe } from "styled-system/recipes";
6
+ import type { AlertStatus } from "./StyledAlert";
7
+
8
+ /**
9
+ * A tag's tone.
10
+ *
11
+ * The four status names ARE `AlertStatus`, referenced rather than retyped, so
12
+ * the package cannot drift into two status vocabularies — a green that means
13
+ * "success" on a banner and "active" on a tag is the kind of divergence nobody
14
+ * notices until a product has both.
15
+ *
16
+ * `neutral` and `accent` extend it. `neutral` is the historical appearance and
17
+ * stays the default; `accent` exists because a tag is frequently just a
18
+ * *category* — a type, a group, a label — and forcing those into `info` would
19
+ * make "informational" mean nothing.
20
+ */
21
+ export type TagTone = "neutral" | AlertStatus | "accent";
5
22
 
6
23
  export interface StyledTagProps
7
24
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "onSelect"> {
8
25
  children: React.ReactNode;
26
+ /**
27
+ * The tag's colour, carrying meaning.
28
+ *
29
+ * ## Colour must not be the only signal (WCAG 1.4.1, Level A)
30
+ *
31
+ * Usually it is not, and that is why there is no forced glyph here: a tag
32
+ * generally *is* its label, so `<StyledTag tone="success">Enabled</StyledTag>`
33
+ * says "enabled" in words and the colour merely reinforces it. `StyledAlert`
34
+ * needs a glyph because a banner's status is genuinely carried by its
35
+ * colouring; a tag's is carried by its text.
36
+ *
37
+ * **The exception is a tag whose label does not name its own state** — a
38
+ * feature name tinted green for on and grey for off, say. There the colour is
39
+ * the only signal and the criterion is unmet, so pass `indicator`.
40
+ */
41
+ tone?: TagTone;
42
+ /**
43
+ * A non-colour signal rendered before the label.
44
+ *
45
+ * Deliberately not defaulted per tone. See `tone` above: defaulting one would
46
+ * put a glyph on every tag in every consumer to fix the minority of cases
47
+ * where the label does not already say what the colour says.
48
+ */
49
+ indicator?: React.ReactNode;
9
50
  /**
10
51
  * Show a remove control, and call this when it is activated.
11
52
  *
@@ -49,33 +90,29 @@ export interface StyledTagProps
49
90
  */
50
91
  export const StyledTag = React.forwardRef<HTMLSpanElement, StyledTagProps>(
51
92
  function StyledTag(
52
- { children, onRemove, removeLabel = "Remove", className, ...rest },
93
+ {
94
+ children,
95
+ tone = "neutral",
96
+ indicator,
97
+ onRemove,
98
+ removeLabel = "Remove",
99
+ className,
100
+ ...rest
101
+ },
53
102
  ref,
54
103
  ) {
55
104
  return (
56
105
  <span
57
106
  ref={ref}
58
- className={cx(
59
- css({
60
- display: "inline-flex",
61
- alignItems: "center",
62
- gap: "1",
63
- paddingInline: "2",
64
- // Vertical padding is deliberately absent: the height comes from
65
- // the line box and the horizontal padding, so a tag tracks the
66
- // font scale instead of needing a re-tune whenever it moves.
67
- borderRadius: "md",
68
- backgroundColor: "boxBgSecondary",
69
- color: "textSecondary",
70
- // Not a tap target: a plain tag is not interactive, so the 48px
71
- // floor does not apply to it. The remove BUTTON below is, and does.
72
- fontSize: "sm",
73
- whiteSpace: "nowrap",
74
- }),
75
- className,
76
- )}
107
+ /*
108
+ * The recipe, not an inline `css()` — see `preset/recipes/tag.ts`. A
109
+ * tone computed at runtime (`tone={STATUS_COLOR[status]}`) is invisible
110
+ * to Panda's extractor, and `staticCssRecipes` is what covers it.
111
+ */
112
+ className={cx(tagRecipe({ tone }), className)}
77
113
  {...rest}
78
114
  >
115
+ {indicator !== undefined && <span aria-hidden="true">{indicator}</span>}
79
116
  <span>{children}</span>
80
117
  {onRemove !== undefined && (
81
118
  <button
package/src/index.ts CHANGED
@@ -204,10 +204,10 @@ export type { StyledFormLabelProps } from "./components/StyledFormLabel";
204
204
  // that stops it working.
205
205
  // ---------------------------------------------------------------------------
206
206
  export { default as StyledLink, StyledLink as Link } from "./components/StyledLink";
207
- export type { StyledLinkProps } from "./components/StyledLink";
207
+ export type { StyledLinkProps, LinkPresentation } from "./components/StyledLink";
208
208
 
209
209
  export { default as StyledTag, StyledTag as Tag } from "./components/StyledTag";
210
- export type { StyledTagProps } from "./components/StyledTag";
210
+ export type { StyledTagProps, TagTone } from "./components/StyledTag";
211
211
 
212
212
  export {
213
213
  default as StyledFieldErrors,
@@ -25,6 +25,7 @@ import {
25
25
  } from "./recipes/separator";
26
26
  import { stackRecipe } from "./recipes/stack";
27
27
  import { stripedRecipe } from "./recipes/striped";
28
+ import { tagRecipe } from "./recipes/tag";
28
29
  import { textRecipe } from "./recipes/text";
29
30
  import { tooltipRecipe } from "./recipes/tooltip";
30
31
 
@@ -83,6 +84,7 @@ const recipes = {
83
84
  separatorVerticalRecipe,
84
85
  stackRecipe,
85
86
  stripedRecipe,
87
+ tagRecipe,
86
88
  textRecipe,
87
89
  tooltipRecipe,
88
90
  };
@@ -135,7 +137,7 @@ const staticCssAlignment = [
135
137
 
136
138
  /**
137
139
  * The @stonedogcode/style Panda preset: colour tokens, breakpoints, keyframes, and
138
- * the 22 recipes the component library is built on.
140
+ * the 23 recipes the component library is built on.
139
141
  *
140
142
  * Deliberately does NOT set `globalCss`, `preflight`, `include`, or `outdir` —
141
143
  * those are application decisions, and a preset that quietly restyles `body` is
@@ -0,0 +1,96 @@
1
+ import { defineRecipe } from "@pandacss/dev";
2
+
3
+ /**
4
+ * A small label, in one of six tones.
5
+ *
6
+ * ## Why this is a recipe and not `css()` in the component
7
+ *
8
+ * `StyledTag` painted itself with an inline `css()` call, which was right while
9
+ * it had exactly one appearance. A tone chosen at runtime is a different
10
+ * problem: Panda's extractor reads source text, so `tone={STATUS_COLOR[status]}`
11
+ * resolves to nothing and emits no rule — while the class name still lands in
12
+ * the DOM. The result is an unstyled tag, with no build error and no console
13
+ * warning.
14
+ *
15
+ * Recipes are the escape from that, because `staticCssRecipes` in
16
+ * `preset/index.ts` forces **every variant of every recipe** into the
17
+ * stylesheet. So a tone computed from a status map is covered for free, which is
18
+ * how the consuming apps actually use this (NEH-721: HopperGuard drives 109 of
19
+ * its 155 tags from `STATUS_COLOR[item.status]` and friends).
20
+ *
21
+ * ## The tones reuse StyledAlert's vocabulary exactly
22
+ *
23
+ * `info` / `success` / `warning` / `error` are `AlertStatus`, and each pairs the
24
+ * **same tokens** the alert recipe pairs. Two status vocabularies in one package
25
+ * — one for banners and a different one for tags — is how a product ends up with
26
+ * a green that means "success" in one place and "active" in another.
27
+ *
28
+ * Two tones are additional rather than borrowed:
29
+ *
30
+ * - **`neutral`** is the historical default and stays the default, so every
31
+ * existing call site renders exactly as it did before this variant existed.
32
+ * - **`accent`** has no alert equivalent, because an alert is always *about*
33
+ * something being fine or not. A tag is often just a category — a label, a
34
+ * type, a group — and forcing those into `info` would make "informational"
35
+ * mean nothing.
36
+ *
37
+ * ## No border, unlike the alert
38
+ *
39
+ * The alert recipe pairs each background with a `border*` token. A tag is small
40
+ * and usually appears in groups; a 1px edge on each turns a row of five into
41
+ * visual noise, and the tinted background already separates it from the page.
42
+ * The border tokens stay available if a consumer disagrees.
43
+ */
44
+ export const tagRecipe = defineRecipe({
45
+ className: "tag",
46
+ base: {
47
+ display: "inline-flex",
48
+ alignItems: "center",
49
+ gap: "1",
50
+ paddingInline: "2",
51
+ /*
52
+ * Vertical padding is deliberately absent: the height comes from the line
53
+ * box and the horizontal padding, so a tag tracks the font scale instead of
54
+ * needing a re-tune whenever it moves.
55
+ */
56
+ borderRadius: "md",
57
+ /*
58
+ * Not a tap target. A plain tag is not interactive, so the 48px floor does
59
+ * not apply to it — the remove BUTTON inside `StyledTag` is, and states its
60
+ * own.
61
+ */
62
+ fontSize: "sm",
63
+ whiteSpace: "nowrap",
64
+ },
65
+ variants: {
66
+ tone: {
67
+ neutral: {
68
+ backgroundColor: "boxBgSecondary",
69
+ color: "textSecondary",
70
+ },
71
+ info: {
72
+ backgroundColor: "boxInfo",
73
+ color: "textMain",
74
+ },
75
+ success: {
76
+ backgroundColor: "boxSuccess",
77
+ color: "textSuccess",
78
+ },
79
+ warning: {
80
+ backgroundColor: "boxWarning",
81
+ color: "textWarning",
82
+ },
83
+ error: {
84
+ backgroundColor: "boxError",
85
+ color: "textError",
86
+ },
87
+ accent: {
88
+ backgroundColor: "boxBgAccent",
89
+ color: "textMain",
90
+ },
91
+ },
92
+ },
93
+ defaultVariants: {
94
+ tone: "neutral",
95
+ },
96
+ });