@glasshome/ui 1.1.2 → 1.3.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": "@glasshome/ui",
3
- "version": "1.1.2",
3
+ "version": "1.3.0",
4
4
  "description": "SolidJS component library for GlassHome, built on Kobalte",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/glasshome/ui#readme",
@@ -53,14 +53,16 @@
53
53
  "src/astro",
54
54
  "src/lib",
55
55
  "src/styles",
56
- "scripts"
56
+ "scripts",
57
+ "SPEC.md"
57
58
  ],
58
59
  "scripts": {
59
- "build": "vite build && tsc",
60
+ "build": "rm -rf dist && vite build && tsc",
61
+ "check:structural": "bun scripts/check-structural-classes.ts",
60
62
  "check:tokens": "bun scripts/check-tokens.ts",
61
63
  "check:types": "tsc --noEmit -p tsconfig.test.json",
62
64
  "check:publish": "publint && attw --pack . --profile esm-only --exclude-entrypoints ./styles ./styles/theme",
63
- "dev": "vite build --watch",
65
+ "dev": "tsc -b --force && vite build --watch",
64
66
  "lint": "biome check .",
65
67
  "test": "vitest run",
66
68
  "test:watch": "vitest",
@@ -0,0 +1,43 @@
1
+ // Gate B3 (sdk-contract-hardening.md): a package class string may not depend on the consumer's
2
+ // Tailwind scanning package source. Arbitrary variants like `[&>svg]:` are the
3
+ // tell: unlike plain utilities, no consumer's own code coincidentally
4
+ // generates them, so they silently vanish for any consumer whose build never
5
+ // scans this package (the stacked-carousel break). Structural child styling
6
+ // ships as real CSS in styles/globals.css instead (see .carousel-stack,
7
+ // .gh-alert-*). Escape hatch: a `structural-ok: <reason>` comment on or
8
+ // directly above the offending line.
9
+ import { readdirSync, readFileSync } from "node:fs";
10
+ import path from "node:path";
11
+
12
+ const LIB_DIR = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../src/lib");
13
+ const ARBITRARY_VARIANT = /\[&[^\]]*\]:/;
14
+ const COMMENT_LINE = /^\s*(\/\/|\/\*|\*)/;
15
+
16
+ let findings = 0;
17
+
18
+ for (const entry of readdirSync(LIB_DIR).sort()) {
19
+ if (!entry.endsWith(".ts") || entry.endsWith(".test.ts")) continue;
20
+ const file = path.join(LIB_DIR, entry);
21
+ const lines = readFileSync(file, "utf8").split("\n");
22
+ lines.forEach((text, i) => {
23
+ if (COMMENT_LINE.test(text)) return;
24
+ if (!ARBITRARY_VARIANT.test(text)) return;
25
+ if (text.includes("structural-ok:")) return;
26
+ // Walk the comment block directly above for the escape marker.
27
+ for (let j = i - 1; j >= 0 && COMMENT_LINE.test(lines[j] ?? ""); j--) {
28
+ if (lines[j]?.includes("structural-ok:")) return;
29
+ }
30
+ findings++;
31
+ console.error(
32
+ `src/lib/${entry}:${i + 1} consumer-generated structural class (arbitrary variant): ${text.trim().slice(0, 120)}`,
33
+ );
34
+ });
35
+ }
36
+
37
+ if (findings > 0) {
38
+ console.error(
39
+ `\n${findings} arbitrary variant(s) in lib class modules. Move the styling to real CSS in src/styles/globals.css (see .carousel-stack) or annotate with a \`structural-ok: <reason>\` comment.`,
40
+ );
41
+ process.exit(1);
42
+ }
43
+ console.log("check-structural-classes: lib class strings are consumer-independent.");
@@ -47,10 +47,12 @@ export const alertIconFill = (c: string) => `color-mix(in srgb, ${c} 16%, transp
47
47
 
48
48
  export const ALERT_CLASS =
49
49
  "relative overflow-hidden flex items-start gap-3 rounded-lg border p-3 backdrop-blur-sm";
50
+ /* gh-alert-* svg chrome lives in globals.css as real CSS: an arbitrary variant
51
+ * here only works if the consumer's Tailwind scans package source. */
50
52
  export const ALERT_ICON_CLASS =
51
- "flex size-8 shrink-0 items-center justify-center rounded-md [&>svg]:size-[18px]";
53
+ "gh-alert-icon flex size-8 shrink-0 items-center justify-center rounded-md";
52
54
  export const ALERT_ICON_BG_CLASS =
53
- "pointer-events-none absolute -right-3 -bottom-4 [&>svg]:size-28 [&>svg]:[stroke-width:2.5px]";
55
+ "gh-alert-icon-bg pointer-events-none absolute -right-3 -bottom-4";
54
56
  export function alertIconBgStyle(color: string): Record<string, string> {
55
57
  return {
56
58
  color,
@@ -61,5 +63,4 @@ export function alertIconBgStyle(color: string): Record<string, string> {
61
63
  }
62
64
  export const ALERT_CONTENT_CLASS = "relative z-10 min-w-0 flex-1";
63
65
  export const ALERT_TITLE_CLASS = "font-semibold text-base leading-snug";
64
- export const ALERT_DESCRIPTION_CLASS =
65
- "text-foreground/80 text-sm leading-snug [&:not(:first-child)]:mt-0.5";
66
+ export const ALERT_DESCRIPTION_CLASS = "gh-alert-desc text-foreground/80 text-sm leading-snug";
@@ -4,6 +4,9 @@ import { cva } from "cva";
4
4
  * muddy over dark heroes/sections. Every class is literal for Tailwind's
5
5
  * scanner. `size: none` is sizeless for callers that own height/padding. */
6
6
  export const buttonVariants = cva({
7
+ // structural-ok: the [&_svg]: utilities stay in Tailwind's utility layer so a
8
+ // caller's own [&_svg]:size-* override can still win; unlayered real CSS in
9
+ // globals.css would beat every consumer utility regardless of specificity.
7
10
  base: "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-full font-medium text-sm outline-none transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
8
11
  variants: {
9
12
  variant: {
@@ -1,5 +1,20 @@
1
- /* Recessed (concave) glass field. Opaque --input fill: a translucent field
2
- * shows whatever sits behind it (worst inside the overlapping Select listbox). */
3
- export const INPUT_SURFACE = "glass glass-sink [--glass-base:var(--input)] [--glass-light:0.04]";
1
+ /* Recessed (concave) glass field: text inputs, textareas, and the pickers that
2
+ * wear a field (Select trigger + listbox, EntitySelector, AreaPicker). The fill
3
+ * and edge come from the theme's --field/--field-edge pair rather than --input
4
+ * directly, because the two themes need different material: on a dark ground a
5
+ * dug-out fill reads as depth, on a light one the same drop reads as `disabled`,
6
+ * so light fields sit at the card and carry a solid border instead. Both are
7
+ * opaque — a translucent field shows whatever sits behind it (worst inside the
8
+ * overlapping Select listbox). The recess itself is .glass-sink's rim, which is
9
+ * theme independent. */
10
+ export const INPUT_SURFACE =
11
+ "glass glass-sink [--glass-base:var(--field)] [--glass-edge:var(--field-edge)] [--glass-light:0.04]";
12
+
13
+ /* Toggle-family chrome and rails: checkbox box, radio ring, switch track, slider
14
+ * rail, chart wells. Same concave glass, but keyed to --input in both themes:
15
+ * these are not fields you type into, they are unfilled controls whose whole job
16
+ * is to read as an empty well, so the fill must stay visibly below the card even
17
+ * in the light theme. */
18
+ export const FIELD_CHROME = "glass glass-sink [--glass-base:var(--input)] [--glass-light:0.04]";
4
19
 
5
20
  export const INPUT_CLASS = `flex h-9 w-full min-w-0 rounded-md ${INPUT_SURFACE} px-3 py-1 text-base outline-none transition-[color,box-shadow] selection:bg-primary selection:text-primary-foreground file:inline-flex file:h-7 file:border-0 file:bg-transparent file:font-medium file:text-foreground file:text-sm placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40`;
@@ -1,7 +1,23 @@
1
- /* Floating-panel glass: opaque popover fill so menus stay legible over anything. */
2
- export const OVERLAY_SURFACE =
3
- "glass [--glass-base:var(--popover)] [--glass-rim:0.3] [--glass-lift:0.55]";
1
+ /* Rim/sheen raised above card level (0.3/0.05) so the bevel and highlight read
2
+ * on large panels instead of smearing into a flat matte gray. */
3
+ const OVERLAY_KNOBS =
4
+ "glass [--glass-rim:1] [--glass-lift:0.6] [--glass-light:0.05] [--glass-edge:color-mix(in_srgb,var(--border)_90%,transparent)]";
5
+
6
+ /* Split from the surface the way CARD_BLUR is, so a host can gate it off and
7
+ * paint a precomputed frost instead. */
8
+ export const OVERLAY_BLUR = "backdrop-blur-[var(--glass-blur,20px)] backdrop-saturate-[1.6]";
9
+
10
+ export const OVERLAY_SURFACE_BASE = `${OVERLAY_KNOBS} [--glass-base:color-mix(in_srgb,var(--popover)_75%,transparent)]`;
11
+
12
+ /* Floating-panel glass: translucent popover fill over a blur, so a menu reads
13
+ * as glass and not as a flat plate. */
14
+ export const OVERLAY_SURFACE = `${OVERLAY_SURFACE_BASE} ${OVERLAY_BLUR}`;
15
+
16
+ /* Opaque fill, no blur: the drag-animated surfaces (BottomSheet), where
17
+ * backdrop-blur is too slow on mobile and a translucent fill with no blur
18
+ * behind it would just be see-through. */
19
+ export const OVERLAY_SURFACE_OPAQUE = `${OVERLAY_KNOBS} [--glass-base:var(--popover)]`;
4
20
 
5
21
  /* Modal scrim behind dialogs/sheets. BottomSheet keeps its own unblurred scrim
6
22
  * (backdrop-blur is too slow on mobile). */
7
- export const SCRIM_CLASS = "bg-background/70 backdrop-blur-sm";
23
+ export const SCRIM_CLASS = "bg-background/70 backdrop-blur-md";
@@ -26,6 +26,15 @@
26
26
  inherits: false;
27
27
  initial-value: 0.05;
28
28
  }
29
+ /* Size of the top-left sheen ellipse, `<x> <y>`. Percentages scale with the
30
+ * element, which is right for cards but leaves an edge-anchored panel (a side
31
+ * sheet is narrow and viewport-tall) with a small blob over a flat matte field.
32
+ * Such surfaces pin absolute lengths so the highlight reads the same as on a
33
+ * dialog. Untyped: two values, so no single @property syntax fits. */
34
+ @property --glass-sheen {
35
+ syntax: "*";
36
+ inherits: false;
37
+ }
29
38
  @property --glass-shade {
30
39
  syntax: "<number>";
31
40
  inherits: false;
@@ -109,18 +118,29 @@
109
118
  * children can read it; a nested .glass re-declares it from its own knob,
110
119
  * which resets the chain and keeps the no-leak guarantee. */
111
120
  --surface-tone: var(--glass-tone);
121
+ /* Second tint axis. Ordinary (inheriting) properties, so they are re-declared
122
+ * on every .glass to reset the chain at each glass boundary (same no-leak
123
+ * trick as --surface-tone). Defaults reproduce the single-tone wash exactly;
124
+ * override tone-2/wash-2/angle together for a two-tone tint (slider fills). */
125
+ --glass-tone-2: var(--glass-tone);
126
+ --glass-wash-2: calc(var(--glass-wash) / 3);
127
+ --glass-wash-angle: 135deg;
112
128
  border: 1px solid var(--glass-edge);
113
129
  /* Base is a gradient layer (not background-color) so the frost slice can sit
114
130
  * BELOW it — background-color always paints under every image layer, which
115
131
  * would put the frost above the fill. Layer order top→bottom: light, shade,
116
132
  * tone wash, base fill, frost. */
117
133
  background:
118
- radial-gradient(120% 120% at 22% 8%, oklch(1 0 0 / var(--glass-light)), transparent 58%),
134
+ radial-gradient(
135
+ var(--glass-sheen, 120% 120%) at 22% 8%,
136
+ oklch(1 0 0 / var(--glass-light)),
137
+ transparent 58%
138
+ ),
119
139
  radial-gradient(120% 120% at 82% 100%, oklch(0 0 0 / var(--glass-shade)), transparent 58%),
120
140
  linear-gradient(
121
- 135deg,
141
+ var(--glass-wash-angle),
122
142
  color-mix(in srgb, var(--glass-tone) var(--glass-wash), transparent),
123
- color-mix(in srgb, var(--glass-tone) calc(var(--glass-wash) / 3), transparent)
143
+ color-mix(in srgb, var(--glass-tone-2) var(--glass-wash-2), transparent)
124
144
  ),
125
145
  linear-gradient(var(--glass-base), var(--glass-base)), var(--glass-frost, none);
126
146
  box-shadow:
@@ -130,6 +150,32 @@
130
150
  0 10px 30px -16px oklch(0 0 0 / var(--glass-lift));
131
151
  }
132
152
 
153
+ /* Two-tone edge: border-color cannot gradient, so the border goes transparent
154
+ * and a masked 1px ring runs tone-1→tone-2 at the .glass-tint edge alpha.
155
+ * Must stay unlayered (the formula's border is unlayered and beats any @layer)
156
+ * and must read the inheriting mirrors, not the inherits:false knobs, which
157
+ * resolve to initial on a pseudo. Element must be positioned. */
158
+ :where(.glass-edge-gradient) {
159
+ border-color: transparent;
160
+ }
161
+ :where(.glass-edge-gradient)::before {
162
+ content: "";
163
+ position: absolute;
164
+ inset: 0;
165
+ border-radius: inherit;
166
+ padding: 1px;
167
+ background: linear-gradient(
168
+ var(--glass-wash-angle),
169
+ oklch(from var(--surface-tone) l c h / 0.45),
170
+ oklch(from var(--glass-tone-2) l c h / 0.45)
171
+ );
172
+ mask:
173
+ linear-gradient(#fff 0 0) content-box,
174
+ linear-gradient(#fff 0 0);
175
+ mask-composite: exclude;
176
+ pointer-events: none;
177
+ }
178
+
133
179
  /* PageHeader banner surface: bespoke three-stop card gradient (its material
134
180
  * predates the one-glass formula) over the same frost slot .glass uses, so
135
181
  * hosts wire the performant-blur engine identically on both. Frost defaults
@@ -171,6 +217,22 @@
171
217
  }
172
218
  }
173
219
 
220
+ /* Alert/toast icon chrome + description spacing: structural (same rationale as
221
+ * .carousel-stack below), so real CSS instead of [&>svg]: arbitrary variants.
222
+ * Values mirror the former utilities: size-[18px], size-28, mt-0.5. */
223
+ .gh-alert-icon > svg {
224
+ width: 18px;
225
+ height: 18px;
226
+ }
227
+ .gh-alert-icon-bg > svg {
228
+ width: 7rem;
229
+ height: 7rem;
230
+ stroke-width: 2.5px;
231
+ }
232
+ .gh-alert-desc:not(:first-child) {
233
+ margin-top: 0.125rem;
234
+ }
235
+
174
236
  /* Stacked carousel modes (fade, wipe) put every slide in one grid cell. This is
175
237
  * structural, so it ships as real CSS: a Tailwind arbitrary variant written in
176
238
  * package source never reaches a consumer, because Tailwind skips node_modules
@@ -126,15 +126,31 @@
126
126
  --muted-foreground: oklch(0.34 0 0);
127
127
  --accent: oklch(0.6 0.2 195);
128
128
  --accent-foreground: oklch(1 0 0);
129
- --success: oklch(0.65 0.17 145);
130
- --warning: oklch(0.75 0.14 85);
129
+ /* Semantic roles are one value per role, used as fill AND as text, so on this
130
+ * L=0.975 ground they have to clear 4.5:1 (3:1 for --ring, a focus indicator)
131
+ * against --background and --card. The dark theme's high-lightness versions
132
+ * measure 2.1-3.6:1 here, so the light theme drops lightness and holds hue;
133
+ * chroma comes down only as far as staying near the sRGB gamut edge needs.
134
+ * Measured vs --background / --card / white-on-fill:
135
+ * success 5.24 / 5.56 / 5.63, warning 5.17 / 5.49 / 5.55,
136
+ * destructive 5.21 / 5.53 / 5.59, ring 4.68 / 4.97 / 5.03. */
137
+ --success: oklch(0.5 0.15 145);
138
+ --warning: oklch(0.52 0.13 85);
131
139
  --primary-tint-foreground: oklch(0.42 0.2 215.221);
132
140
  --accent-tint-foreground: oklch(0.46 0.16 195);
133
- --destructive: oklch(0.629 0.1902 23.0704);
141
+ --destructive: oklch(0.54 0.19 23.0704);
134
142
  --destructive-foreground: oklch(1 0 0);
135
143
  --border: oklch(0.9 0.01 250);
136
144
  --input: oklch(0.9 0.012 250);
137
- --ring: oklch(0.6 0.2 195);
145
+ --ring: oklch(0.53 0.18 195);
146
+ /* Field material (INPUT_SURFACE). The recess is the .glass-sink rim, not a
147
+ * darker fill: on a card of L=0.995 an --input fill of L=0.9 is a nine-point
148
+ * drop, the same cue this library spends on `disabled`, so a form of light
149
+ * fields reads switched off. Light fields sit AT the card and get a solid
150
+ * --border edge instead — the boundary still measures 1.33:1 against the
151
+ * card, exactly what the fill drop used to carry, at 1px instead of a slab. */
152
+ --field: var(--card);
153
+ --field-edge: var(--border);
138
154
  --chart-1: oklch(0.7459 0.1483 156.4499);
139
155
  --chart-2: oklch(0.48 0.2 215.221);
140
156
  --chart-3: oklch(0.7336 0.1758 50.5517);
@@ -192,11 +208,16 @@
192
208
  --destructive: oklch(0.7106 0.1661 22.2162);
193
209
  --destructive-foreground: oklch(1 0 0);
194
210
  --border: oklch(0.26 0.012 250);
195
- /* Recessed fields read DARKER than the card they're dug out of (card is 0.17),
196
- * so a field surface sits below it, matching the slider track. A hair of the
197
- * card's cool tint (chroma 0.01) keeps it from going flat gray. */
211
+ /* On a dark ground a dug-out surface catches more light than the wall around
212
+ * it, so the field sits a touch ABOVE the card (0.17) rather than below, and
213
+ * the .glass-sink rim does the rest. A hair of the card's cool tint (chroma
214
+ * 0.01) keeps it from going flat gray. Same rail color as the slider track. */
198
215
  --input: oklch(0.19 0.01 250);
199
216
  --ring: oklch(0.6 0.2 195);
217
+ /* Dark fields keep the dug-out fill (it reads as depth here, not as disabled)
218
+ * and the glass formula's own soft edge. See the light block. */
219
+ --field: var(--input);
220
+ --field-edge: color-mix(in srgb, var(--border) 60%, transparent);
200
221
  --chart-1: oklch(0.8003 0.1821 151.711);
201
222
  --chart-2: oklch(0.48 0.2 215.221);
202
223
  --chart-3: oklch(0.8077 0.1035 19.5706);