@sableui/react 0.1.1 → 0.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/AGENTS.md +3 -1
- package/CHANGELOG.md +120 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +1 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +68 -26
- package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
- package/dist/blocks/index.d.ts +396 -0
- package/dist/blocks/index.js +1997 -0
- package/dist/chunk-6LCIBMMG.js +4908 -0
- package/dist/{chunk-J23ELQQZ.js → chunk-CKNM7KSB.js} +1664 -4270
- package/dist/chunk-K6LII3OE.js +5093 -0
- package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
- package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
- package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
- package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
- package/dist/foundations/index.js +1 -1
- package/dist/icon/index.js +2 -2
- package/dist/icon-sets/reicon.js +1 -1
- package/dist/index.d.ts +624 -133
- package/dist/index.js +767 -4964
- package/dist/ios/index.d.ts +14 -2
- package/dist/ios/index.js +10 -9
- package/dist/ios/preview.d.ts +11 -1
- package/dist/ios/preview.js +14 -9
- package/dist/ios/styles.css +26 -6
- package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
- package/dist/patterns/index.d.ts +1 -1
- package/dist/patterns/index.js +1684 -471
- package/dist/presets/index.d.ts +1 -1
- package/dist/presets/index.js +1 -1
- package/dist/styles.css +238 -40
- package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
- package/dist/themes/atelier/atelier.css +3 -0
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/base/base.css +34 -4
- package/dist/themes/base/index.d.ts +1 -1
- package/dist/themes/ember/ember.css +3 -0
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/index.js +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/meridian/meridian.css +10 -5
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/poppy/poppy.css +16 -0
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/prism/prism.css +4 -1
- package/dist/themes/verdant/index.d.ts +1 -1
- package/dist/themes/verdant/verdant.css +7 -2
- package/llms/blocks/api-keys-block.md +79 -0
- package/llms/blocks/chat-thread-block.md +51 -0
- package/llms/blocks/command-palette-block.md +48 -0
- package/llms/blocks/cta-band-block.md +62 -0
- package/llms/blocks/danger-zone-block.md +69 -0
- package/llms/blocks/device-comparison-block.md +91 -0
- package/llms/blocks/event-card-block.md +84 -0
- package/llms/blocks/faq-block.md +72 -0
- package/llms/blocks/feature-grid-block.md +75 -0
- package/llms/blocks/feedback-form-block.md +66 -0
- package/llms/blocks/goal-progress-block.md +54 -0
- package/llms/blocks/hero-block.md +65 -0
- package/llms/blocks/invite-team-block.md +61 -0
- package/llms/blocks/invoice-history-block.md +84 -0
- package/llms/blocks/kpi-sparkline-block.md +57 -0
- package/llms/blocks/media-player-block.md +100 -0
- package/llms/blocks/newsletter-block.md +64 -0
- package/llms/blocks/not-found-block.md +56 -0
- package/llms/blocks/notification-list-block.md +63 -0
- package/llms/blocks/notification-settings-block.md +54 -0
- package/llms/blocks/order-tracking-block.md +62 -0
- package/llms/blocks/pricing-card-block.md +82 -0
- package/llms/blocks/pricing-table-block.md +129 -0
- package/llms/blocks/product-card-block.md +69 -0
- package/llms/blocks/profile-card-block.md +103 -0
- package/llms/blocks/profile-form-block.md +114 -0
- package/llms/blocks/revenue-breakdown-block.md +70 -0
- package/llms/blocks/sign-in-block.md +64 -0
- package/llms/blocks/sign-up-block.md +78 -0
- package/llms/blocks/site-footer-block.md +96 -0
- package/llms/blocks/stat-row-block.md +59 -0
- package/llms/blocks/steps-block.md +73 -0
- package/llms/blocks/task-list-block.md +81 -0
- package/llms/blocks/team-members-block.md +91 -0
- package/llms/blocks/testimonial-block.md +78 -0
- package/llms/blocks/traffic-sources-block.md +63 -0
- package/llms/blocks/traffic-trend-block.md +95 -0
- package/llms/blocks/upload-list-block.md +66 -0
- package/llms/blocks/verify-code-block.md +59 -0
- package/llms/blocks/wallet-block.md +84 -0
- package/llms/components/area-chart.md +112 -0
- package/llms/components/bar-chart.md +131 -0
- package/llms/components/bubble.md +5 -5
- package/llms/components/button.md +1 -1
- package/llms/components/card.md +2 -2
- package/llms/components/checkbox-card.md +1 -1
- package/llms/components/context-card.md +2 -2
- package/llms/components/context-menu.md +1 -1
- package/llms/components/date-tile.md +79 -0
- package/llms/components/dialog.md +1 -1
- package/llms/components/empty-state.md +4 -4
- package/llms/components/entity.md +5 -3
- package/llms/components/fieldset.md +1 -1
- package/llms/components/kbd.md +18 -6
- package/llms/components/line-chart.md +101 -0
- package/llms/components/menu.md +1 -1
- package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
- package/llms/components/pie-chart.md +103 -0
- package/llms/components/pill.md +1 -1
- package/llms/components/radar-chart.md +106 -0
- package/llms/components/radial-chart.md +102 -0
- package/llms/components/segmented-bar.md +102 -0
- package/llms/components/settings-menu.md +2 -2
- package/llms/components/side-nav.md +1 -1
- package/llms/components/skeleton.md +1 -1
- package/llms/components/sparkline.md +80 -0
- package/llms/components/table.md +1 -1
- package/llms/components/theme-selector.md +1 -1
- package/llms/foundations/shape.md +1 -1
- package/llms/patterns/auth-page.md +132 -42
- package/llms/patterns/brand-landing-page.md +271 -175
- package/llms/patterns/chat-panel.md +120 -39
- package/llms/patterns/dashboard-page.md +276 -65
- package/llms/patterns/feed-page.md +245 -64
- package/llms/patterns/landing-page.md +473 -75
- package/llms/patterns/list-detail-page.md +377 -68
- package/llms/patterns/settings-page.md +268 -67
- package/llms/reference/token-intent.md +4 -2
- package/llms/reference/utilities.md +20 -5
- package/llms/tasks/building-an-ios-prototype.md +4 -1
- package/llms/tasks/choosing-a-component.md +2 -2
- package/llms-full.txt +6428 -892
- package/llms.txt +67 -16
- package/package.json +5 -1
- package/registry.json +3217 -552
- package/targets/ios.json +88 -18
- package/llms/components/description.md +0 -96
- package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
|
@@ -156,6 +156,8 @@
|
|
|
156
156
|
--border: oklch(88% 0.008 74);
|
|
157
157
|
--border-strong: oklch(82% 0.010 73);
|
|
158
158
|
--input: oklch(85% 0.009 74);
|
|
159
|
+
/* The resting edge of a control whose outline IS the control (checkbox, radio, an off switch track). Held to WCAG 1.4.11 3:1 on every surface, unlike the quieter --input field outline. */
|
|
160
|
+
--control-border: oklch(60.2% 0.0095 70);
|
|
159
161
|
--ring: oklch(58% 0.245 32.9);
|
|
160
162
|
--primary: oklch(58% 0.245 32.9);
|
|
161
163
|
--primary-foreground: oklch(100% 0 0);
|
|
@@ -234,6 +236,7 @@
|
|
|
234
236
|
--border: oklch(32% 0.012 67.2); /* lifted with the fills: a hairline must still show on muted */
|
|
235
237
|
--border-strong: oklch(37% 0.010 61.1);
|
|
236
238
|
--input: oklch(33% 0.012 65);
|
|
239
|
+
--control-border: oklch(55.6% 0.0084 67.7);
|
|
237
240
|
--ring: oklch(65.2% 0.235 32.9);
|
|
238
241
|
--primary: oklch(65.2% 0.235 32.9);
|
|
239
242
|
--primary-foreground: oklch(14.1% 0.018 70.5);
|
package/dist/themes/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { A as AnyThemeId, D as DEFAULT_THEME_ID, S as SABLE_COLOR_STORAGE_KEY, a as SABLE_THEME_ATTRIBUTE, b as SABLE_THEME_STORAGE_KEY, c as SableColorMode, d as SableThemeContextValue, e as SableThemePreference, f as SableThemeProvider, g as SableThemeProviderProps, T as ThemeId, h as applySableColorMode, i as applySableTheme, j as buildThemeManifestById, k as defaultThemeId, l as featuredThemeManifests, m as getThemeManifest, n as isKnownThemeId, o as isThemeId, t as themeIds, p as themeManifestById, q as themeManifests, u as useSableTheme } from '../theme-provider-
|
|
1
|
+
export { A as AnyThemeId, D as DEFAULT_THEME_ID, S as SABLE_COLOR_STORAGE_KEY, a as SABLE_THEME_ATTRIBUTE, b as SABLE_THEME_STORAGE_KEY, c as SableColorMode, d as SableThemeContextValue, e as SableThemePreference, f as SableThemeProvider, g as SableThemeProviderProps, T as ThemeId, h as applySableColorMode, i as applySableTheme, j as buildThemeManifestById, k as defaultThemeId, l as featuredThemeManifests, m as getThemeManifest, n as isKnownThemeId, o as isThemeId, t as themeIds, p as themeManifestById, q as themeManifests, u as useSableTheme } from '../theme-provider-BE-KqUG-.js';
|
|
2
2
|
export { atelierTheme } from './atelier/index.js';
|
|
3
3
|
export { baseTheme } from './base/index.js';
|
|
4
4
|
export { default as emberTheme } from './ember/index.js';
|
|
@@ -7,4 +7,4 @@ export { default as poppyTheme } from './poppy/index.js';
|
|
|
7
7
|
export { default as prismTheme } from './prism/index.js';
|
|
8
8
|
export { default as verdantTheme } from './verdant/index.js';
|
|
9
9
|
import 'react';
|
|
10
|
-
import '../_contract-
|
|
10
|
+
import '../_contract-o3tUc8Ig.js';
|
package/dist/themes/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export { DEFAULT_THEME_ID, SABLE_COLOR_STORAGE_KEY, SABLE_THEME_ATTRIBUTE, SABLE_THEME_STORAGE_KEY, SableThemeProvider, applySableColorMode, applySableTheme, buildThemeManifestById, defaultThemeId, featuredThemeManifests, getThemeManifest, isKnownThemeId, isThemeId, themeIds, themeManifestById, themeManifests, useSableTheme } from '../chunk-
|
|
1
|
+
export { DEFAULT_THEME_ID, SABLE_COLOR_STORAGE_KEY, SABLE_THEME_ATTRIBUTE, SABLE_THEME_STORAGE_KEY, SableThemeProvider, applySableColorMode, applySableTheme, buildThemeManifestById, defaultThemeId, featuredThemeManifests, getThemeManifest, isKnownThemeId, isThemeId, themeIds, themeManifestById, themeManifests, useSableTheme } from '../chunk-MXJX3NZI.js';
|
|
2
|
+
export { prismTheme } from '../chunk-7MG4SHYX.js';
|
|
2
3
|
export { verdantTheme } from '../chunk-KO3NAEBF.js';
|
|
3
4
|
export { emberTheme } from '../chunk-L4IEIQQA.js';
|
|
4
5
|
export { baseTheme } from '../chunk-EP55KSYA.js';
|
|
5
6
|
export { poppyTheme } from '../chunk-ADLLDWLW.js';
|
|
6
7
|
export { meridianTheme } from '../chunk-K7RUHUGC.js';
|
|
7
8
|
export { atelierTheme } from '../chunk-YXBOYWBN.js';
|
|
8
|
-
export { prismTheme } from '../chunk-7MG4SHYX.js';
|
|
@@ -117,15 +117,19 @@
|
|
|
117
117
|
---------------------------------------------------------------------- */
|
|
118
118
|
--background: oklch(94.6% 0.0083 91.48);
|
|
119
119
|
--foreground: oklch(26.25% 0.0162 102.18);
|
|
120
|
-
|
|
120
|
+
/* A step above the paper page, as in every preset with a tinted page: a card
|
|
121
|
+
level with it left fields reading as part of the page, not a box to type in. */
|
|
122
|
+
--card: oklch(97.6% 0.006 91.48);
|
|
121
123
|
--card-foreground: oklch(26.25% 0.0162 102.18);
|
|
122
|
-
--popover: oklch(
|
|
124
|
+
--popover: oklch(97.6% 0.006 91.48);
|
|
123
125
|
--popover-foreground: oklch(26.25% 0.0162 102.18);
|
|
124
126
|
--muted: oklch(91.24% 0.0138 92.99);
|
|
125
127
|
--muted-foreground: oklch(49.93% 0.0412 93.54);
|
|
126
128
|
--border: oklch(87.54% 0.0194 93.69);
|
|
127
129
|
--border-strong: oklch(81.94% 0.0252 94.12);
|
|
128
130
|
--input: oklch(87.54% 0.0194 93.69);
|
|
131
|
+
/* The resting edge of a control whose outline IS the control (checkbox, radio, an off switch track). Held to WCAG 1.4.11 3:1 on every surface, unlike the quieter --input field outline. */
|
|
132
|
+
--control-border: oklch(58.9% 0.0346 93.54);
|
|
129
133
|
--ring: oklch(62% 0.2063 32.56);
|
|
130
134
|
--primary: oklch(26.25% 0.0162 102.18);
|
|
131
135
|
--primary-foreground: oklch(94.6% 0.0083 91.48);
|
|
@@ -167,7 +171,7 @@
|
|
|
167
171
|
--sidebar-primary-foreground: oklch(22.46% 0.0094 107.13);
|
|
168
172
|
--sidebar-accent: oklch(27.1% 0.0143 101.41);
|
|
169
173
|
--sidebar-accent-foreground: oklch(94.3% 0.0042 91.45);
|
|
170
|
-
--sidebar-border: oklch(
|
|
174
|
+
--sidebar-border: oklch(34.69% 0.0186 98.75);
|
|
171
175
|
--sidebar-ring: oklch(65.11% 0.2063 32.56);
|
|
172
176
|
|
|
173
177
|
/* ----------------------------------------------------------------------
|
|
@@ -216,8 +220,9 @@
|
|
|
216
220
|
* only the edge separates. Caught by the non-text contrast gate.
|
|
217
221
|
*/
|
|
218
222
|
--border: oklch(34.69% 0.0186 98.75);
|
|
219
|
-
--border-strong: oklch(
|
|
223
|
+
--border-strong: oklch(44% 0.0225 96); /* one visible step above --border, which was lifted to the old strong value */
|
|
220
224
|
--input: oklch(34.69% 0.0186 98.75);
|
|
225
|
+
--control-border: oklch(60% 0.002 0);
|
|
221
226
|
--ring: oklch(65.11% 0.2063 32.56);
|
|
222
227
|
--primary: oklch(94.3% 0.0042 91.45);
|
|
223
228
|
--primary-foreground: oklch(22.46% 0.0094 107.13);
|
|
@@ -250,6 +255,6 @@
|
|
|
250
255
|
--sidebar-primary-foreground: oklch(22.46% 0.0094 107.13);
|
|
251
256
|
--sidebar-accent: oklch(27.1% 0.0143 101.41);
|
|
252
257
|
--sidebar-accent-foreground: oklch(94.3% 0.0042 91.45);
|
|
253
|
-
--sidebar-border: oklch(
|
|
258
|
+
--sidebar-border: oklch(34.69% 0.0186 98.75);
|
|
254
259
|
--sidebar-ring: oklch(65.11% 0.2063 32.56);
|
|
255
260
|
}
|
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
--border: oklch(69.96908% 0.055090 333.3586);
|
|
70
70
|
--border-strong: oklch(54.87925% 0.056525 333.9456);
|
|
71
71
|
--input: oklch(69.96908% 0.055090 333.3586);
|
|
72
|
+
/* The resting edge of a control whose outline IS the control (checkbox, radio, an off switch track). Held to WCAG 1.4.11 3:1 on every surface, unlike the quieter --input field outline. */
|
|
73
|
+
--control-border: oklch(60% 0.0598 321.39);
|
|
72
74
|
--ring: oklch(40.99289% 0.130336 325.3125);
|
|
73
75
|
--primary: oklch(38.38082% 0.116390 323.6155);
|
|
74
76
|
--primary-foreground: oklch(100.00000% 0.000000 89.8756);
|
|
@@ -119,14 +121,27 @@
|
|
|
119
121
|
--spacing: 0.28rem;
|
|
120
122
|
--theme-button-radius: 12px;
|
|
121
123
|
--button-radius: var(--theme-button-radius);
|
|
124
|
+
/* Hard offsets the whole way up. Only sm and md were declared, so the
|
|
125
|
+
default, lg and xl steps fell through to Base's soft blurs and one ladder
|
|
126
|
+
spoke two shadow languages. Offset doubles roughly per step. */
|
|
122
127
|
--theme-shadow-sm: 3px 3px 0 oklch(32% 0.05 320 / 0.12);
|
|
128
|
+
--theme-shadow: 4px 4px 0 oklch(32% 0.05 320 / 0.13);
|
|
123
129
|
--theme-shadow-md: 5px 5px 0 oklch(32% 0.05 320 / 0.14);
|
|
130
|
+
--theme-shadow-lg: 8px 8px 0 oklch(32% 0.05 320 / 0.16);
|
|
131
|
+
--theme-shadow-xl: 12px 12px 0 oklch(32% 0.05 320 / 0.18);
|
|
124
132
|
}
|
|
125
133
|
|
|
126
134
|
.dark[data-sable-theme="poppy"],
|
|
127
135
|
[data-sable-theme="poppy"].dark,
|
|
128
136
|
[data-sable-theme="poppy"] .dark,
|
|
129
137
|
.dark [data-sable-theme="poppy"]:not(.light) {
|
|
138
|
+
/* The light ladder's ink is 32% lightness — LIGHTER than this 20% page, so
|
|
139
|
+
in dark mode it drew a faint glow, not a shadow. Same offsets, near-black. */
|
|
140
|
+
--theme-shadow-sm: 3px 3px 0 oklch(8% 0.03 320 / 0.5);
|
|
141
|
+
--theme-shadow: 4px 4px 0 oklch(8% 0.03 320 / 0.52);
|
|
142
|
+
--theme-shadow-md: 5px 5px 0 oklch(8% 0.03 320 / 0.55);
|
|
143
|
+
--theme-shadow-lg: 8px 8px 0 oklch(8% 0.03 320 / 0.6);
|
|
144
|
+
--theme-shadow-xl: 12px 12px 0 oklch(8% 0.03 320 / 0.65);
|
|
130
145
|
--background: oklch(20.33265% 0.047438 308.8247);
|
|
131
146
|
--foreground: oklch(96.99788% 0.031195 84.5922);
|
|
132
147
|
--card: oklch(28.82474% 0.059607 315.2029);
|
|
@@ -138,6 +153,7 @@
|
|
|
138
153
|
--border: oklch(62.48980% 0.070560 325.5308);
|
|
139
154
|
--border-strong: oklch(72.17143% 0.060424 328.9840);
|
|
140
155
|
--input: oklch(62.48980% 0.070560 325.5308);
|
|
156
|
+
--control-border: oklch(67.3% 0.0412 332.63);
|
|
141
157
|
--ring: oklch(86.64637% 0.118528 86.6791);
|
|
142
158
|
--primary: oklch(86.64637% 0.118528 86.6791);
|
|
143
159
|
--primary-foreground: oklch(27.91553% 0.070219 323.8141);
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
--region-ultrawide: 1800px;
|
|
13
13
|
--theme-radius-none: 0px;
|
|
14
14
|
--theme-radius-xs: 2px;
|
|
15
|
-
--theme-radius-sm: 4px
|
|
15
|
+
--theme-radius-sm: 3px; /* a step of its own — sm and md were both 4px, which erased a rung of the ladder */
|
|
16
16
|
--theme-radius-md: 4px;
|
|
17
17
|
--theme-radius-lg: 6px;
|
|
18
18
|
--theme-radius-xl: 8px;
|
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
--border: oklch(79.84882% 0.029246 251.5080);
|
|
70
70
|
--border-strong: oklch(64.27577% 0.042824 253.1390);
|
|
71
71
|
--input: oklch(79.84882% 0.029246 251.5080);
|
|
72
|
+
/* The resting edge of a control whose outline IS the control (checkbox, radio, an off switch track). Held to WCAG 1.4.11 3:1 on every surface, unlike the quieter --input field outline. */
|
|
73
|
+
--control-border: oklch(60% 0.0298 252.77);
|
|
72
74
|
--ring: oklch(50.45463% 0.203215 263.5596);
|
|
73
75
|
--primary: oklch(50.45463% 0.203215 263.5596);
|
|
74
76
|
--primary-foreground: oklch(100.00000% 0.000000 89.8756);
|
|
@@ -147,6 +149,7 @@
|
|
|
147
149
|
--border: oklch(58.06940% 0.052170 251.5856);
|
|
148
150
|
--border-strong: oklch(69.19219% 0.042050 253.1082);
|
|
149
151
|
--input: oklch(58.06940% 0.052170 251.5856);
|
|
152
|
+
--control-border: oklch(67.7% 0.0275 255.1);
|
|
150
153
|
--ring: oklch(82.28684% 0.088371 261.1846);
|
|
151
154
|
--primary: oklch(82.28684% 0.088371 261.1846);
|
|
152
155
|
--primary-foreground: oklch(25.20350% 0.047984 259.9119);
|
|
@@ -90,15 +90,19 @@
|
|
|
90
90
|
---------------------------------------------------------------------- */
|
|
91
91
|
--background: oklch(98.6% 0.004 106);
|
|
92
92
|
--foreground: oklch(24% 0.035 175);
|
|
93
|
-
|
|
93
|
+
/* Cards, fields and popovers sit at or above the page, never recessed into it:
|
|
94
|
+
a card darker than the page made every field read as a filled grey box. */
|
|
95
|
+
--card: oklch(99.7% 0.002 106);
|
|
94
96
|
--card-foreground: oklch(24% 0.035 175);
|
|
95
|
-
--popover: oklch(
|
|
97
|
+
--popover: oklch(99.7% 0.002 106);
|
|
96
98
|
--popover-foreground: oklch(24% 0.035 175);
|
|
97
99
|
--muted: oklch(93% 0.007 106);
|
|
98
100
|
--muted-foreground: oklch(49% 0.018 175);
|
|
99
101
|
--border: oklch(88.5% 0.005 106);
|
|
100
102
|
--border-strong: oklch(81% 0.008 130);
|
|
101
103
|
--input: oklch(88.5% 0.005 106);
|
|
104
|
+
/* The resting edge of a control whose outline IS the control (checkbox, radio, an off switch track). Held to WCAG 1.4.11 3:1 on every surface, unlike the quieter --input field outline. */
|
|
105
|
+
--control-border: oklch(59.9% 0.0149 175);
|
|
102
106
|
--ring: oklch(24% 0.035 175);
|
|
103
107
|
--primary: oklch(24% 0.035 175);
|
|
104
108
|
--primary-foreground: oklch(98.6% 0.004 106);
|
|
@@ -174,6 +178,7 @@
|
|
|
174
178
|
--border: oklch(36% 0.03 175);
|
|
175
179
|
--border-strong: oklch(40% 0.028 175);
|
|
176
180
|
--input: oklch(36% 0.03 175);
|
|
181
|
+
--control-border: oklch(58.7% 0.0158 172);
|
|
177
182
|
--ring: oklch(85% 0.015 170);
|
|
178
183
|
--primary: oklch(98.6% 0.004 106);
|
|
179
184
|
--primary-foreground: oklch(24% 0.035 175);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# ApiKeysBlock
|
|
2
|
+
|
|
3
|
+
An API keys card: each key's name with its masked hint in Code, when it was created and last used, and an OverflowMenu to rename, roll or revoke.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this for developer settings — API keys, access tokens, webhook secrets. Show a newly created key once, in a Dialog with a copy button; this list only ever shows the masked hint.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 20rem–42rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Only the last four characters are shown. A full key never appears in a list, so there is no copy action here.
|
|
12
|
+
- "Last used" sits in the detail line: it is what tells someone a key is safe to revoke.
|
|
13
|
+
- Revoke is the destructive item, last and after a separator; confirm it in your handler with DestructiveActionModal.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each OverflowMenu is labelled with the key's name, so the triggers are distinguishable.
|
|
18
|
+
- The masked hint is a Code element inside the title, read after the key's name.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { Button, Card, CardAction, CardDescription, CardHeader, CardList, CardTitle, cn, Code, Entity, Icon, MenuItem, MenuSeparator, OverflowMenu } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const keys = [
|
|
26
|
+
{ id: "k1", name: "Production", hint: "sk_live_••••4f2a", detail: "Created Mar 2 · used 4 minutes ago" },
|
|
27
|
+
{ id: "k2", name: "Staging", hint: "sk_test_••••91cd", detail: "Created Feb 11 · used yesterday" },
|
|
28
|
+
{ id: "k3", name: "CI deploys", hint: "sk_live_••••0b7e", detail: "Created Jan 5 · never used" },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export interface ApiKeysBlockProps {
|
|
32
|
+
className?: string
|
|
33
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
34
|
+
framed?: boolean
|
|
35
|
+
onCreate?: () => void
|
|
36
|
+
/** Called with the key's id and the action chosen from its menu. */
|
|
37
|
+
onKeyAction?: (id: string, action: "rename" | "roll" | "revoke") => void
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ApiKeysBlock({ className, framed = true, onCreate, onKeyAction }: ApiKeysBlockProps) {
|
|
41
|
+
return (
|
|
42
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
|
|
43
|
+
<CardHeader>
|
|
44
|
+
<CardTitle render={<h2 />}>API keys</CardTitle>
|
|
45
|
+
<CardDescription>A key is shown once, when you create it. Store it somewhere safe.</CardDescription>
|
|
46
|
+
<CardAction>
|
|
47
|
+
<Button size="sm" variant="outline" iconStart={<Icon name="key" />} onClick={onCreate}>
|
|
48
|
+
Create key
|
|
49
|
+
</Button>
|
|
50
|
+
</CardAction>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardList>
|
|
53
|
+
{keys.map((k) => (
|
|
54
|
+
<Entity
|
|
55
|
+
key={k.id}
|
|
56
|
+
title={
|
|
57
|
+
<span className="flex flex-wrap items-center gap-x-2">
|
|
58
|
+
{k.name}
|
|
59
|
+
<Code>{k.hint}</Code>
|
|
60
|
+
</span>
|
|
61
|
+
}
|
|
62
|
+
subtitle={k.detail}
|
|
63
|
+
action={
|
|
64
|
+
<OverflowMenu label={`Manage the ${k.name} key`} size="icon-sm">
|
|
65
|
+
<MenuItem onClick={() => onKeyAction?.(k.id, "rename")}>Rename</MenuItem>
|
|
66
|
+
<MenuItem onClick={() => onKeyAction?.(k.id, "roll")}>Roll key</MenuItem>
|
|
67
|
+
<MenuSeparator />
|
|
68
|
+
<MenuItem variant="destructive" onClick={() => onKeyAction?.(k.id, "revoke")}>
|
|
69
|
+
Revoke
|
|
70
|
+
</MenuItem>
|
|
71
|
+
</OverflowMenu>
|
|
72
|
+
}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</CardList>
|
|
76
|
+
</Card>
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
```
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# ChatThreadBlock
|
|
2
|
+
|
|
3
|
+
A short message thread on a card: Message rows with incoming muted Bubbles on the start side and your own primary Bubbles on the end.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to show a conversation excerpt — a support preview, a comment thread on a dashboard, a recent-DM card. A working chat with a composer, streaming and history is the ChatPanel page shell.
|
|
6
|
+
|
|
7
|
+
**Block:** Social & messaging · card · designed for 16rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Sit it on a card, not straight on a muted page: incoming bubbles are the muted surface, and would vanish into it.
|
|
12
|
+
- Your own messages align to the end with the primary Bubble; everyone else's align to the start.
|
|
13
|
+
|
|
14
|
+
## Accessibility
|
|
15
|
+
|
|
16
|
+
- Each Message carries a messageId, so a thread can be addressed and announced item by item.
|
|
17
|
+
- Direction is shown by alignment and colour; when senders matter, add the sender's name to each Message.
|
|
18
|
+
|
|
19
|
+
## Source
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import { Bubble, Card, CardContent, cn, Message } from "@sableui/react"
|
|
23
|
+
|
|
24
|
+
const messages = [
|
|
25
|
+
{ id: "m1", text: "Is the new cover ready?", mine: false },
|
|
26
|
+
{ id: "m2", text: "Uploading it now 🎉", mine: true },
|
|
27
|
+
{ id: "m3", text: "Perfect, thank you!", mine: false },
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
export interface ChatThreadBlockProps {
|
|
31
|
+
className?: string
|
|
32
|
+
/** false drops the card's surface and border. Keep it framed on a muted page: incoming bubbles are the muted surface too. */
|
|
33
|
+
framed?: boolean
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function ChatThreadBlock({ className, framed = true }: ChatThreadBlockProps) {
|
|
37
|
+
return (
|
|
38
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
|
|
39
|
+
<CardContent className="flex flex-col gap-2">
|
|
40
|
+
{messages.map((m) => (
|
|
41
|
+
<Message key={m.id} messageId={m.id} align={m.mine ? "end" : undefined}>
|
|
42
|
+
<Bubble variant={m.mine ? "primary" : undefined} align={m.mine ? "end" : undefined}>
|
|
43
|
+
{m.text}
|
|
44
|
+
</Bubble>
|
|
45
|
+
</Message>
|
|
46
|
+
))}
|
|
47
|
+
</CardContent>
|
|
48
|
+
</Card>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
```
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# CommandPaletteBlock
|
|
2
|
+
|
|
3
|
+
An inline command palette: CommandMenuPanel with a filter field and grouped actions, each with an icon and its keyboard shortcut.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this to show a command list in place — an empty state that teaches shortcuts, an onboarding step, a quick-actions card. The ⌘K palette that opens over the app is CommandMenu; this is its panel without the dialog.
|
|
6
|
+
|
|
7
|
+
**Block:** Navigation · card · designed for 18rem–20rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Each command shows its shortcut as a Kbd hint, so the palette also teaches the keys.
|
|
12
|
+
- `onCommand` receives the command's id; ids also key the rows, so two commands may share a label.
|
|
13
|
+
- No card around it: the panel is its own surface, lifted with `shadow-sm` when it sits on a muted section.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- CommandMenuPanel wires the filter input to the list as a combobox, with arrow-key navigation and the active row announced.
|
|
18
|
+
- Shortcut hints are Kbd elements, read as keys.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { cn, CommandMenuPanel, Kbd, type CommandItem } from "@sableui/react"
|
|
24
|
+
|
|
25
|
+
const commands = [
|
|
26
|
+
{ id: "new-project", icon: "plus", label: "New project", key: "N" },
|
|
27
|
+
{ id: "invite", icon: "user", label: "Invite teammate", key: "I" },
|
|
28
|
+
{ id: "settings", icon: "gear", label: "Open settings", key: "," },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
export interface CommandPaletteBlockProps {
|
|
32
|
+
className?: string
|
|
33
|
+
/** Called with the chosen command's id. */
|
|
34
|
+
onCommand?: (id: string) => void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CommandPaletteBlock({ className, onCommand }: CommandPaletteBlockProps) {
|
|
38
|
+
const items: CommandItem[] = commands.map((c) => ({
|
|
39
|
+
id: c.id,
|
|
40
|
+
group: "Actions",
|
|
41
|
+
icon: c.icon,
|
|
42
|
+
label: c.label,
|
|
43
|
+
hint: <Kbd>{c.key}</Kbd>,
|
|
44
|
+
onSelect: () => onCommand?.(c.id),
|
|
45
|
+
}))
|
|
46
|
+
return <CommandMenuPanel className={cn("w-full max-w-xs shadow-sm", className)} placeholder="Type a command…" items={items} />
|
|
47
|
+
}
|
|
48
|
+
```
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# CtaBandBlock
|
|
2
|
+
|
|
3
|
+
A closing call to action: a centred h2 and one-line lede on a card, with a primary and an outline action.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the last section before the footer, repeating the hero's primary action once the reader has seen the case. Mid-page prompts to sign up belong inline, not in a band.
|
|
6
|
+
|
|
7
|
+
**Block:** Marketing · section
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the LandingPage page shell's closing call to action.
|
|
12
|
+
- The primary action matches the hero's, so the page asks for one thing twice rather than two things once.
|
|
13
|
+
- Sitting on a Card lifts it from the page without a coloured band, so it follows any preset.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- The section is labelled by its h2.
|
|
18
|
+
- Both actions are links styled as Buttons, because they navigate.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useId } from "react"
|
|
24
|
+
import { Button, Card, CardContent, cn, Region, Row, Stack } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const cta = {
|
|
27
|
+
title: "Build the product, not another component library",
|
|
28
|
+
lede: "Install Sable, pick a preset, and ship the first screen today.",
|
|
29
|
+
primary: { label: "Get started", href: "/docs/getting-started" },
|
|
30
|
+
secondary: { label: "Star on GitHub", href: "/github" },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CtaBandBlockProps {
|
|
34
|
+
className?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CtaBandBlock({ className }: CtaBandBlockProps) {
|
|
38
|
+
const titleId = useId()
|
|
39
|
+
return (
|
|
40
|
+
<Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16", className)}>
|
|
41
|
+
<Card>
|
|
42
|
+
<CardContent>
|
|
43
|
+
<Stack gap={4} align="center" className="py-8 text-center sm:py-12">
|
|
44
|
+
<h2 id={titleId} className="max-w-2xl text-balance text-heading-1">
|
|
45
|
+
{cta.title}
|
|
46
|
+
</h2>
|
|
47
|
+
<p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">{cta.lede}</p>
|
|
48
|
+
<Row gap={2} justify="center" wrap className="pt-2">
|
|
49
|
+
<Button size="cta" render={<a href={cta.primary.href} />}>
|
|
50
|
+
{cta.primary.label}
|
|
51
|
+
</Button>
|
|
52
|
+
<Button size="cta" variant="outline" render={<a href={cta.secondary.href} />}>
|
|
53
|
+
{cta.secondary.label}
|
|
54
|
+
</Button>
|
|
55
|
+
</Row>
|
|
56
|
+
</Stack>
|
|
57
|
+
</CardContent>
|
|
58
|
+
</Card>
|
|
59
|
+
</Region>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# DangerZoneBlock
|
|
2
|
+
|
|
3
|
+
A delete-account card: what deleting removes and what survives, one destructive Button, and a DestructiveActionModal that asks for the handle to be typed before it acts.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this as the last section of account, workspace or project settings. Deleting a single record from a list is an OverflowMenu item that opens DestructiveActionModal directly, not a card.
|
|
6
|
+
|
|
7
|
+
**Block:** Settings & team · card · designed for 20rem–42rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- Copied from the SettingsPage page shell's Delete account section.
|
|
12
|
+
- The button never acts directly: it opens the modal, whose confirm stays disabled until the handle is typed.
|
|
13
|
+
- The description says what is lost AND what is kept ("Collectors keep the editions they own"), which is what people want to know before they decide.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- DestructiveActionModal is an alertdialog: focus opens on Cancel and the backdrop does not dismiss it.
|
|
18
|
+
- The CardTitle is an h2, so the section is reachable by heading.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { Button, Card, CardDescription, CardFooter, CardHeader, CardTitle, cn, DestructiveActionModal } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
const account = { handle: "ada", editions: 12 }
|
|
27
|
+
|
|
28
|
+
export interface DangerZoneBlockProps {
|
|
29
|
+
className?: string
|
|
30
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
31
|
+
framed?: boolean
|
|
32
|
+
/** Called once the person has typed the handle and confirmed. */
|
|
33
|
+
onDelete?: () => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function DangerZoneBlock({ className, framed = true, onDelete }: DangerZoneBlockProps) {
|
|
37
|
+
const [open, setOpen] = useState(false)
|
|
38
|
+
return (
|
|
39
|
+
<>
|
|
40
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
|
|
41
|
+
<CardHeader>
|
|
42
|
+
<CardTitle render={<h2 />}>Delete account</CardTitle>
|
|
43
|
+
<CardDescription>
|
|
44
|
+
Permanently remove your profile, editions, and sales history. Collectors keep the editions they own.
|
|
45
|
+
</CardDescription>
|
|
46
|
+
</CardHeader>
|
|
47
|
+
<CardFooter className="justify-end">
|
|
48
|
+
<Button variant="destructive" onClick={() => setOpen(true)}>
|
|
49
|
+
Delete account
|
|
50
|
+
</Button>
|
|
51
|
+
</CardFooter>
|
|
52
|
+
</Card>
|
|
53
|
+
<DestructiveActionModal
|
|
54
|
+
open={open}
|
|
55
|
+
onOpenChange={setOpen}
|
|
56
|
+
title="Delete your account?"
|
|
57
|
+
description={`This permanently removes your profile, ${account.editions} editions, and sales history. Pending payouts are still sent. This can't be undone.`}
|
|
58
|
+
confirmLabel="Delete account"
|
|
59
|
+
confirmingLabel="Deleting…"
|
|
60
|
+
confirmText={account.handle}
|
|
61
|
+
onConfirm={() => {
|
|
62
|
+
setOpen(false)
|
|
63
|
+
onDelete?.()
|
|
64
|
+
}}
|
|
65
|
+
/>
|
|
66
|
+
</>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
```
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# DeviceComparisonBlock
|
|
2
|
+
|
|
3
|
+
A comparison card: each series' total sits in the header as a pressable tile, and the one pressed is plotted below as a daily BarChart.
|
|
4
|
+
|
|
5
|
+
**Intent:** Reach for this when two or three measures share a page and the reader looks at one at a time — devices, regions, plans. To see them together, plot both in one BarChart or LineChart instead.
|
|
6
|
+
|
|
7
|
+
**Block:** Dashboard · card · designed for 20rem–48rem wide (fills its container up to the max)
|
|
8
|
+
|
|
9
|
+
## Notes
|
|
10
|
+
|
|
11
|
+
- The totals are the switch: Toggles in a labelled ToggleGroup, so the number and the control are one thing, with the group's own pressed and hover states.
|
|
12
|
+
- Switching eases every bar to its new height rather than redrawing.
|
|
13
|
+
- Below sm the tiles stack under the title and divide with a top rule instead of a side rule.
|
|
14
|
+
|
|
15
|
+
## Accessibility
|
|
16
|
+
|
|
17
|
+
- Each total is a Toggle (aria-pressed) named by its label and value, in a ToggleGroup labelled "Device" with arrow-key movement between tiles.
|
|
18
|
+
- The chart's name says which series is plotted, so the data table is unambiguous.
|
|
19
|
+
|
|
20
|
+
## Source
|
|
21
|
+
|
|
22
|
+
```tsx
|
|
23
|
+
import { useState } from "react"
|
|
24
|
+
import { BarChart, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Toggle, ToggleGroup } from "@sableui/react"
|
|
25
|
+
|
|
26
|
+
/** 30 days of visitors — replace with your own rows ({ date, desktop, mobile }). */
|
|
27
|
+
const days = Array.from({ length: 30 }, (_, i) => ({
|
|
28
|
+
date: new Date(2026, 8, 1 + i).toISOString().slice(0, 10),
|
|
29
|
+
desktop: Math.round(320 + Math.sin(i / 3) * 90 + (i % 5) * 14),
|
|
30
|
+
mobile: Math.round(260 + Math.cos(i / 4) * 80 + (i % 4) * 18),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const series = [
|
|
34
|
+
{ key: "desktop", label: "Desktop" },
|
|
35
|
+
{ key: "mobile", label: "Mobile" },
|
|
36
|
+
] as const
|
|
37
|
+
|
|
38
|
+
const shortDate = (iso: string) => new Date(`${iso}T00:00:00`).toLocaleDateString("en-US", { month: "short", day: "numeric" })
|
|
39
|
+
|
|
40
|
+
export interface DeviceComparisonBlockProps {
|
|
41
|
+
className?: string
|
|
42
|
+
/** false drops the card's surface and border — for a muted section that already frames it. */
|
|
43
|
+
framed?: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function DeviceComparisonBlock({ className, framed = true }: DeviceComparisonBlockProps) {
|
|
47
|
+
const [active, setActive] = useState<string>("desktop")
|
|
48
|
+
const totals = Object.fromEntries(series.map((s) => [s.key, days.reduce((sum, d) => sum + d[s.key], 0)]))
|
|
49
|
+
const current = series.find((s) => s.key === active) ?? series[0]
|
|
50
|
+
return (
|
|
51
|
+
// overflow-hidden: the square tiles meet the card's rounded corner.
|
|
52
|
+
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-3xl overflow-hidden", className)}>
|
|
53
|
+
<div className="flex flex-col border-b border-border sm:flex-row">
|
|
54
|
+
<CardHeader className="flex-1 pb-[var(--card-p,--spacing(6))]">
|
|
55
|
+
<CardTitle render={<h2 />}>Visitors by device</CardTitle>
|
|
56
|
+
<CardDescription>The last 30 days. Pick a device to plot it.</CardDescription>
|
|
57
|
+
</CardHeader>
|
|
58
|
+
{/* The totals ARE the switch: one pressed tile per series. */}
|
|
59
|
+
<ToggleGroup
|
|
60
|
+
value={[active]}
|
|
61
|
+
onValueChange={(v: unknown[]) => v[0] && setActive(String(v[0]))}
|
|
62
|
+
aria-label="Device"
|
|
63
|
+
className="items-stretch gap-0"
|
|
64
|
+
>
|
|
65
|
+
{series.map((s) => (
|
|
66
|
+
<Toggle
|
|
67
|
+
key={s.key}
|
|
68
|
+
value={s.key}
|
|
69
|
+
className="h-auto flex-1 flex-col items-start justify-center gap-1 rounded-none border-border px-6 py-4 text-start text-foreground max-sm:border-t sm:border-s"
|
|
70
|
+
>
|
|
71
|
+
<span className="text-caption text-muted-foreground">{s.label}</span>
|
|
72
|
+
<span className="text-mono-lg">{totals[s.key].toLocaleString("en-US")}</span>
|
|
73
|
+
</Toggle>
|
|
74
|
+
))}
|
|
75
|
+
</ToggleGroup>
|
|
76
|
+
</div>
|
|
77
|
+
<CardContent>
|
|
78
|
+
<BarChart
|
|
79
|
+
label={`${current.label} visitors per day, last 30 days`}
|
|
80
|
+
data={days}
|
|
81
|
+
index="date"
|
|
82
|
+
series={[current]}
|
|
83
|
+
indexFormatter={shortDate}
|
|
84
|
+
radius={3}
|
|
85
|
+
className="h-64"
|
|
86
|
+
/>
|
|
87
|
+
</CardContent>
|
|
88
|
+
</Card>
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
```
|