sapient-ai 0.1.1 → 0.2.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 +4 -1
- package/bin/sapient-ai.js +292 -5
- package/local-registry/r/button.json +6 -2
- package/local-registry/r/customer-satisfaction.json +1 -1
- package/local-registry/r/multiple-choice-card.json +2 -2
- package/local-registry/r/multiple-choice-grid.json +1 -1
- package/local-registry/r/multiple-choice-list.json +1 -1
- package/local-registry/r/news-card.json +1 -1
- package/local-registry/r/privacy-consent.json +1 -1
- package/local-registry/r/product-card.json +1 -1
- package/local-registry/r/profile-card.json +1 -1
- package/local-registry/r/promo-card.json +1 -1
- package/local-registry/r/video-card.json +2 -2
- package/local-registry/scripts/build-registry.mjs +1 -0
- package/local-registry/src/components/ui/sapient-button.tsx +5 -3
- package/local-registry/src/components/ui/sapient-customer-satisfaction.tsx +3 -3
- package/local-registry/src/components/ui/sapient-icon.tsx +44 -3
- package/local-registry/src/components/ui/sapient-multiple-choice-card.tsx +6 -6
- package/local-registry/src/components/ui/sapient-multiple-choice-grid.tsx +3 -3
- package/local-registry/src/components/ui/sapient-multiple-choice-list.tsx +1 -1
- package/local-registry/src/components/ui/sapient-news-card.tsx +9 -9
- package/local-registry/src/components/ui/sapient-privacy-consent.tsx +8 -8
- package/local-registry/src/components/ui/sapient-product-card.tsx +13 -13
- package/local-registry/src/components/ui/sapient-profile-card.tsx +7 -7
- package/local-registry/src/components/ui/sapient-promo-card.tsx +9 -9
- package/local-registry/src/components/ui/sapient-radio-button.tsx +1 -1
- package/local-registry/src/components/ui/sapient-video-card.tsx +3 -3
- package/local-registry/src/components/ui/sapient-video-controller.tsx +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,6 +7,7 @@ Thin wrapper around `shadcn` for Sapient registry usage.
|
|
|
7
7
|
- `sapient-ai init`
|
|
8
8
|
- `sapient-ai init --preset <handle-or-id> --template next`
|
|
9
9
|
- `sapient-ai add <component>`
|
|
10
|
+
- `sapient-ai add-icons <design-system|lucide|remix>`
|
|
10
11
|
- `sapient-ai shadcn <args...>`
|
|
11
12
|
|
|
12
13
|
## Behavior
|
|
@@ -16,6 +17,8 @@ Thin wrapper around `shadcn` for Sapient registry usage.
|
|
|
16
17
|
- The CLI also generates a `sapient-theme.css` file next to the app's `globals.css` file and imports it automatically so the runtime theme matches the exported Sapient preset.
|
|
17
18
|
- The CLI installs a small Sapient foundation pack by default, then adds any additional registry components inferred from the preset.
|
|
18
19
|
- When the preset references supported Sapient registry items, the CLI also runs `shadcn add` for those components automatically.
|
|
20
|
+
- When the preset uses `lucide` or `remix`, the CLI also installs the matching icon package automatically.
|
|
21
|
+
- `add-icons <library>` updates local Sapient config and installs the matching icon package for an existing project.
|
|
19
22
|
- `add button` is rewritten to `add @sapient/button`.
|
|
20
23
|
- Pass fully-qualified names (`@scope/name`) unchanged.
|
|
21
24
|
- Internally, `init` still uses `shadcn` for project wiring, but it now supplies Sapient-owned defaults so the user does not have to answer the underlying `shadcn` prompts.
|
|
@@ -24,7 +27,7 @@ Thin wrapper around `shadcn` for Sapient registry usage.
|
|
|
24
27
|
|
|
25
28
|
```bash
|
|
26
29
|
npm pack ./packages/cli
|
|
27
|
-
npx --yes ./sapient-ai-0.
|
|
30
|
+
npx --yes ./sapient-ai-0.2.0.tgz --help
|
|
28
31
|
```
|
|
29
32
|
|
|
30
33
|
## Registry URL
|
package/bin/sapient-ai.js
CHANGED
|
@@ -24,12 +24,14 @@ Usage:
|
|
|
24
24
|
sapient-ai init [shadcn-init-args...]
|
|
25
25
|
sapient-ai init --preset <handle-or-id> [shadcn-init-args...]
|
|
26
26
|
sapient-ai add <component> [shadcn-add-args...]
|
|
27
|
+
sapient-ai add-icons <design-system|lucide|remix> [--cwd <path>]
|
|
27
28
|
sapient-ai shadcn [raw-shadcn-args...]
|
|
28
29
|
|
|
29
30
|
Examples:
|
|
30
31
|
sapient-ai init
|
|
31
32
|
sapient-ai init --preset ferrari --template next
|
|
32
33
|
sapient-ai add button
|
|
34
|
+
sapient-ai add-icons remix
|
|
33
35
|
sapient-ai add @sapient/button
|
|
34
36
|
sapient-ai shadcn diff
|
|
35
37
|
|
|
@@ -342,6 +344,73 @@ function getTokenReference(tokenMap, name, fallback) {
|
|
|
342
344
|
return Object.prototype.hasOwnProperty.call(tokenMap, name) ? `var(${name})` : fallback;
|
|
343
345
|
}
|
|
344
346
|
|
|
347
|
+
function resolveSapientExportFont(config) {
|
|
348
|
+
const fontFallback =
|
|
349
|
+
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
350
|
+
|
|
351
|
+
if (config?.font === "custom-google" && typeof config?.customFontUrl === "string") {
|
|
352
|
+
try {
|
|
353
|
+
const parsedUrl = new URL(config.customFontUrl);
|
|
354
|
+
let family = "";
|
|
355
|
+
let importUrl = null;
|
|
356
|
+
|
|
357
|
+
if (parsedUrl.hostname === "fonts.googleapis.com") {
|
|
358
|
+
const familyParam = parsedUrl.searchParams.get("family");
|
|
359
|
+
family = familyParam
|
|
360
|
+
? decodeURIComponent(familyParam.split(":")[0].replace(/\+/g, " ")).trim()
|
|
361
|
+
: "";
|
|
362
|
+
importUrl = parsedUrl.toString();
|
|
363
|
+
} else if (parsedUrl.hostname === "fonts.google.com") {
|
|
364
|
+
const specimenMatch = parsedUrl.pathname.match(/^\/specimen\/([^/]+)$/);
|
|
365
|
+
family = specimenMatch?.[1]
|
|
366
|
+
? decodeURIComponent(specimenMatch[1].replace(/\+/g, " ")).trim()
|
|
367
|
+
: "";
|
|
368
|
+
importUrl = family
|
|
369
|
+
? `https://fonts.googleapis.com/css2?family=${family.replace(/\s+/g, "+")}&display=swap`
|
|
370
|
+
: null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (importUrl && family) {
|
|
374
|
+
return {
|
|
375
|
+
importUrl,
|
|
376
|
+
fontFamily: `"${family}", ${fontFallback}`,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
} catch {
|
|
380
|
+
// Fall through to non-custom handling.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (config?.font === "inter") {
|
|
385
|
+
return {
|
|
386
|
+
importUrl: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap",
|
|
387
|
+
fontFamily: `"Inter", ${fontFallback}`,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (config?.font === "helvetica-neue") {
|
|
392
|
+
return { importUrl: null, fontFamily: '"Helvetica Neue", Helvetica, Arial, sans-serif' };
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (config?.font === "georgia") {
|
|
396
|
+
return { importUrl: null, fontFamily: 'Georgia, "Times New Roman", serif' };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (config?.font === "trebuchet") {
|
|
400
|
+
return { importUrl: null, fontFamily: '"Trebuchet MS", "Lucida Grande", sans-serif' };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (typeof config?.customFontName === "string" && config.customFontName.trim().length > 0) {
|
|
404
|
+
return { importUrl: null, fontFamily: `"${config.customFontName.trim()}", ${fontFallback}` };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
importUrl: null,
|
|
409
|
+
fontFamily:
|
|
410
|
+
'"Neue Haas Grotesk Display Pro", ui-sans-serif, system-ui, sans-serif',
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
345
414
|
function buildSapientThemeCss({ tokens, config }) {
|
|
346
415
|
const tokenMap = toTokenMap(tokens);
|
|
347
416
|
const baseNeutral = getTokenReference(tokenMap, "--neutral-50", "0 0% 98%");
|
|
@@ -363,16 +432,13 @@ function buildSapientThemeCss({ tokens, config }) {
|
|
|
363
432
|
const cardRadius = getTokenReference(tokenMap, "--card-radius", radius);
|
|
364
433
|
const basePadding = typeof config?.padding === "number" ? `${config.padding}px` : "16px";
|
|
365
434
|
const baseGap = typeof config?.gap === "number" ? `${config.gap}px` : "12px";
|
|
366
|
-
const fontFamily =
|
|
367
|
-
typeof config?.font === "string" && config.font.length > 0 && config.font !== "default"
|
|
368
|
-
? `'${config.font}', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`
|
|
369
|
-
: `ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
|
|
435
|
+
const { importUrl: fontImportUrl, fontFamily } = resolveSapientExportFont(config);
|
|
370
436
|
|
|
371
437
|
const tokenLines = Object.entries(tokenMap)
|
|
372
438
|
.map(([name, value]) => ` ${name}: ${String(value)};`)
|
|
373
439
|
.join("\n");
|
|
374
440
|
|
|
375
|
-
return
|
|
441
|
+
return `${fontImportUrl ? `@import url("${fontImportUrl}");\n\n` : ""}:root {
|
|
376
442
|
${tokenLines}
|
|
377
443
|
--background: ${baseNeutral};
|
|
378
444
|
--foreground: ${foregroundPrimary};
|
|
@@ -541,6 +607,185 @@ function applySapientPresetTheme(projectRoot = process.cwd()) {
|
|
|
541
607
|
process.stdout.write(`Applied Sapient theme to ${cssPath}\n`);
|
|
542
608
|
}
|
|
543
609
|
|
|
610
|
+
function normalizeIconLibrary(value) {
|
|
611
|
+
if (value === "lucide" || value === "remix") {
|
|
612
|
+
return value;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
if (value === "sapient" || value === "sapient-icons" || value === "design-system") {
|
|
616
|
+
return "design-system";
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function collectIconLibrariesFromValue(value, foundLibraries = new Set()) {
|
|
623
|
+
if (Array.isArray(value)) {
|
|
624
|
+
for (const item of value) {
|
|
625
|
+
collectIconLibrariesFromValue(item, foundLibraries);
|
|
626
|
+
}
|
|
627
|
+
return foundLibraries;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (!value || typeof value !== "object") {
|
|
631
|
+
return foundLibraries;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
635
|
+
if (key === "iconLibrary" && typeof nestedValue === "string") {
|
|
636
|
+
const normalized = normalizeIconLibrary(nestedValue);
|
|
637
|
+
if (normalized) {
|
|
638
|
+
foundLibraries.add(normalized);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
collectIconLibrariesFromValue(nestedValue, foundLibraries);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return foundLibraries;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function getPresetIconLibraries(projectRoot = process.cwd()) {
|
|
649
|
+
const configPath = findExistingArtifactPath("sapient-design-system-config.json", projectRoot);
|
|
650
|
+
const componentCatalogPath = findExistingArtifactPath("sapient-components.json", projectRoot);
|
|
651
|
+
const componentLibraryPath = findExistingArtifactPath("sapient-component-library.json", projectRoot);
|
|
652
|
+
const foundLibraries = new Set();
|
|
653
|
+
|
|
654
|
+
[configPath, componentCatalogPath, componentLibraryPath]
|
|
655
|
+
.map((filePath) => readJsonFileIfExists(filePath))
|
|
656
|
+
.filter(Boolean)
|
|
657
|
+
.forEach((artifact) => {
|
|
658
|
+
collectIconLibrariesFromValue(artifact, foundLibraries);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
return foundLibraries;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function detectPackageManager(projectRoot = process.cwd()) {
|
|
665
|
+
if (fs.existsSync(path.join(projectRoot, "pnpm-lock.yaml"))) {
|
|
666
|
+
return { command: "pnpm", args: ["add"] };
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (fs.existsSync(path.join(projectRoot, "yarn.lock"))) {
|
|
670
|
+
return { command: "yarn", args: ["add"] };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (
|
|
674
|
+
fs.existsSync(path.join(projectRoot, "bun.lockb")) ||
|
|
675
|
+
fs.existsSync(path.join(projectRoot, "bun.lock"))
|
|
676
|
+
) {
|
|
677
|
+
return { command: "bun", args: ["add"] };
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return { command: "npm", args: ["install"] };
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function hasInstalledDependency(projectRoot, packageName) {
|
|
684
|
+
const packageJsonPath = path.join(projectRoot, "package.json");
|
|
685
|
+
const packageJson = readJsonFileIfExists(packageJsonPath);
|
|
686
|
+
|
|
687
|
+
if (!packageJson || typeof packageJson !== "object") {
|
|
688
|
+
return false;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return Boolean(
|
|
692
|
+
packageJson.dependencies?.[packageName] ||
|
|
693
|
+
packageJson.devDependencies?.[packageName] ||
|
|
694
|
+
packageJson.peerDependencies?.[packageName]
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function installPresetIconDependencies(projectRoot = process.cwd()) {
|
|
699
|
+
const packageMap = {
|
|
700
|
+
lucide: "lucide-react",
|
|
701
|
+
remix: "@remixicon/react",
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const requiredPackages = Array.from(getPresetIconLibraries(projectRoot))
|
|
705
|
+
.filter((library) => library !== "design-system")
|
|
706
|
+
.map((library) => packageMap[library])
|
|
707
|
+
.filter(Boolean)
|
|
708
|
+
.filter((packageName) => !hasInstalledDependency(projectRoot, packageName));
|
|
709
|
+
|
|
710
|
+
if (requiredPackages.length === 0) {
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const { command, args } = detectPackageManager(projectRoot);
|
|
715
|
+
const result = spawnSync(command, [...args, ...requiredPackages], {
|
|
716
|
+
stdio: "inherit",
|
|
717
|
+
env: process.env,
|
|
718
|
+
cwd: projectRoot,
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
if (result.error) {
|
|
722
|
+
throw new Error(`Failed to install icon dependencies: ${result.error.message}`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
if ((result.status ?? 1) !== 0) {
|
|
726
|
+
throw new Error("Icon dependency installation failed.");
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
process.stdout.write(`Installed icon dependencies: ${requiredPackages.join(", ")}\n`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function installSpecificIconLibrary(projectRoot = process.cwd(), library) {
|
|
733
|
+
const packageMap = {
|
|
734
|
+
lucide: "lucide-react",
|
|
735
|
+
remix: "@remixicon/react",
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
const packageName = packageMap[library];
|
|
739
|
+
if (!packageName || hasInstalledDependency(projectRoot, packageName)) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const { command, args } = detectPackageManager(projectRoot);
|
|
744
|
+
const result = spawnSync(command, [...args, packageName], {
|
|
745
|
+
stdio: "inherit",
|
|
746
|
+
env: process.env,
|
|
747
|
+
cwd: projectRoot,
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
if (result.error) {
|
|
751
|
+
throw new Error(`Failed to install ${packageName}: ${result.error.message}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
if ((result.status ?? 1) !== 0) {
|
|
755
|
+
throw new Error(`Installation failed for ${packageName}.`);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
process.stdout.write(`Installed icon dependency: ${packageName}\n`);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function updateComponentsIconLibrary(projectRoot = process.cwd(), library) {
|
|
762
|
+
const componentsPath = path.join(projectRoot, "components.json");
|
|
763
|
+
const components = readJsonFileIfExists(componentsPath);
|
|
764
|
+
|
|
765
|
+
if (!components || typeof components !== "object") {
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
components.iconLibrary = library === "design-system" ? "lucide" : library;
|
|
770
|
+
writeJsonFile(componentsPath, components);
|
|
771
|
+
process.stdout.write(`Updated components.json iconLibrary to ${components.iconLibrary}\n`);
|
|
772
|
+
return true;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function updateSapientConfigIconLibrary(projectRoot = process.cwd(), library) {
|
|
776
|
+
const configPath = findExistingArtifactPath("sapient-design-system-config.json", projectRoot);
|
|
777
|
+
const config = readJsonFileIfExists(configPath);
|
|
778
|
+
|
|
779
|
+
if (!config || typeof config !== "object") {
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
config.iconLibrary = library;
|
|
784
|
+
writeJsonFile(configPath, config);
|
|
785
|
+
process.stdout.write(`Updated .sapient/sapient-design-system-config.json iconLibrary to ${library}\n`);
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
|
|
544
789
|
function installRegistryComponents(preset, projectRoot = process.cwd()) {
|
|
545
790
|
if (!Array.isArray(preset?.registryComponents) || preset.registryComponents.length === 0) {
|
|
546
791
|
return;
|
|
@@ -635,6 +880,7 @@ async function handleInit(args) {
|
|
|
635
880
|
writePresetFiles(preset, projectRoot);
|
|
636
881
|
applySapientPresetTheme(projectRoot);
|
|
637
882
|
installRegistryComponents(preset, projectRoot);
|
|
883
|
+
installPresetIconDependencies(projectRoot);
|
|
638
884
|
process.stdout.write(`Applied preset ${preset.presetHandle || selectedPreset}\n`);
|
|
639
885
|
process.exit(0);
|
|
640
886
|
} catch (error) {
|
|
@@ -668,6 +914,43 @@ function handleAdd(args) {
|
|
|
668
914
|
process.exit(result.status ?? 1);
|
|
669
915
|
}
|
|
670
916
|
|
|
917
|
+
function handleAddIcons(args) {
|
|
918
|
+
if (args.length === 0) {
|
|
919
|
+
process.stderr.write("Missing icon library. Example: sapient-ai add-icons lucide\n");
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const [rawLibrary] = args;
|
|
924
|
+
const library = normalizeIconLibrary(rawLibrary);
|
|
925
|
+
|
|
926
|
+
if (!library) {
|
|
927
|
+
process.stderr.write('Unsupported icon library. Use "design-system", "lucide", or "remix".\n');
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const explicitCwd = getOptionValue(args, "--cwd", "-c");
|
|
932
|
+
const projectRoot = explicitCwd
|
|
933
|
+
? path.resolve(process.cwd(), explicitCwd)
|
|
934
|
+
: process.cwd();
|
|
935
|
+
|
|
936
|
+
try {
|
|
937
|
+
updateComponentsIconLibrary(projectRoot, library);
|
|
938
|
+
updateSapientConfigIconLibrary(projectRoot, library);
|
|
939
|
+
|
|
940
|
+
if (library === "design-system") {
|
|
941
|
+
process.stdout.write("Sapient design-system icons selected. No external icon package is required.\n");
|
|
942
|
+
process.exit(0);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
installSpecificIconLibrary(projectRoot, library);
|
|
946
|
+
process.exit(0);
|
|
947
|
+
} catch (error) {
|
|
948
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
949
|
+
process.stderr.write(`Failed to add icons: ${message}\n`);
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
671
954
|
function handleRawShadcn(args) {
|
|
672
955
|
const result = runShadcn(args);
|
|
673
956
|
|
|
@@ -692,6 +975,10 @@ if (command === "add") {
|
|
|
692
975
|
handleAdd(rest);
|
|
693
976
|
}
|
|
694
977
|
|
|
978
|
+
if (command === "add-icons") {
|
|
979
|
+
handleAddIcons(rest);
|
|
980
|
+
}
|
|
981
|
+
|
|
695
982
|
if (command === "shadcn") {
|
|
696
983
|
handleRawShadcn(rest);
|
|
697
984
|
}
|
|
@@ -55,12 +55,16 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-icon.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nconst ICON_BASE_URL = \"https://pub-3f6483ae20764a8fbcfcf8b0b7dc076a.r2.dev\";\nconst FLAGS_BASE_URL = \"https://pub-40d153c17ab64cc8abb11157721f0d1d.r2.dev\";\n\nexport type SapientIconVariant = \"general\" | \"flags\";\
|
|
58
|
+
"content": "import * as React from \"react\";\nimport * as LucideIcons from \"lucide-react\";\nimport * as RemixIcons from \"@remixicon/react\";\nimport { cn } from \"@/lib/utils\";\n\nconst ICON_BASE_URL = \"https://pub-3f6483ae20764a8fbcfcf8b0b7dc076a.r2.dev\";\nconst FLAGS_BASE_URL = \"https://pub-40d153c17ab64cc8abb11157721f0d1d.r2.dev\";\n\nexport type SapientIconVariant = \"general\" | \"flags\";\nexport type SapientIconLibrary = \"sapient\" | \"lucide\" | \"remix\";\n\nexport interface SapientIconProps {\n name: string;\n label?: string;\n size?: number;\n variant?: SapientIconVariant;\n library?: SapientIconLibrary;\n className?: string;\n color?: string;\n strokeWidth?: number;\n}\n\nexport function SapientIcon({\n name,\n label,\n size = 20,\n variant = \"general\",\n library = \"sapient\",\n className,\n color,\n strokeWidth = 2,\n ...props\n}: SapientIconProps) {\n if (library === \"lucide\") {\n const LucideIcon =\n (LucideIcons as Record<string, React.ComponentType<any>>)[name] ??\n LucideIcons.HelpCircle;\n\n return (\n <LucideIcon\n aria-label={label ?? name}\n size={size}\n color={color}\n strokeWidth={strokeWidth}\n className={cn(\"shrink-0\", className)}\n {...props}\n />\n );\n }\n\n if (library === \"remix\") {\n const RemixIcon =\n (RemixIcons as Record<string, React.ComponentType<any>>)[name] ??\n RemixIcons.RiQuestionLine;\n\n return (\n <RemixIcon\n aria-label={label ?? name}\n size={size}\n color={color}\n className={cn(\"shrink-0\", className)}\n {...props}\n />\n );\n }\n\n const baseUrl = variant === \"flags\" ? FLAGS_BASE_URL : ICON_BASE_URL;\n const src = `${baseUrl}/${name}.svg`;\n\n return (\n <img\n src={src}\n alt={label ?? name}\n width={size}\n height={size}\n className={cn(\"shrink-0\", className)}\n loading=\"lazy\"\n decoding=\"async\"\n />\n );\n}\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "components/ui/sapient-button.tsx",
|
|
62
62
|
"type": "registry:ui",
|
|
63
|
-
"content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { SapientIcon } from \"@/components/ui/sapient-icon\";\n\nexport interface SapientButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n}\n\nexport function SapientButton({\n icon,\n iconPosition = \"left\",\n className,\n children,\n ...props\n}: SapientButtonProps) {\n return (\n <button\n className={cn(\n \"inline-flex h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n className\n )}\n {...props}\n >\n {icon && iconPosition === \"left\" ? <SapientIcon name={icon} size={18} /> : null}\n <span>{children}</span>\n {icon && iconPosition === \"right\" ? <SapientIcon name={icon} size={18} /> : null}\n </button>\n );\n}\n"
|
|
63
|
+
"content": "import * as React from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { SapientIcon, type SapientIconLibrary } from \"@/components/ui/sapient-icon\";\n\nexport interface SapientButtonProps\n extends React.ButtonHTMLAttributes<HTMLButtonElement> {\n icon?: string;\n iconPosition?: \"left\" | \"right\";\n iconLibrary?: SapientIconLibrary;\n}\n\nexport function SapientButton({\n icon,\n iconPosition = \"left\",\n iconLibrary = \"sapient\",\n className,\n children,\n ...props\n}: SapientButtonProps) {\n return (\n <button\n className={cn(\n \"inline-flex h-10 items-center justify-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50\",\n className\n )}\n {...props}\n >\n {icon && iconPosition === \"left\" ? <SapientIcon name={icon} size={18} library={iconLibrary} /> : null}\n <span>{children}</span>\n {icon && iconPosition === \"right\" ? <SapientIcon name={icon} size={18} library={iconLibrary} /> : null}\n </button>\n );\n}\n"
|
|
64
64
|
}
|
|
65
|
+
],
|
|
66
|
+
"dependencies": [
|
|
67
|
+
"lucide-react",
|
|
68
|
+
"@remixicon/react"
|
|
65
69
|
]
|
|
66
70
|
}
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-customer-satisfaction.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "'use client';\n\nimport React, { useState, useId } from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface CustomerSatisfactionProps {\n className?: string;\n /** Question text shown as the card heading */\n question?: string;\n /** Min slider label (left) */\n minLabel?: string;\n /** Max slider label (right) */\n maxLabel?: string;\n /** Initial value 0–100 */\n defaultValue?: number;\n /** Controlled value 0–100 */\n value?: number;\n onChange?: (value: number) => void;\n /** Left-side label (two lines, e.g. [\"PER NIENTE\", \"SODDISFATTO\"]) */\n minLabelLines?: [string, string];\n /** Right-side label (two lines) */\n maxLabelLines?: [string, string];\n}\n\n// ─── Face illustration (pure SVG) ────────────────────────────────────────────\n// Exact coordinates extracted from Figma SVG exports.\n// Viewbox: 0 0 162 131 (matches both exported SVGs)\n//\n// Frame 4254 = Sad (v = 0)\n// Frame 4255 = Happy (v = 100)\n//\n// All numeric path coordinates are linearly interpolated so the face\n// morphs smoothly as the slider moves.\n\n// Helper: lerp between sad (a) and happy (b) values based on 0–100 slider\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * (t / 100);\n}\n\n// Round to 3 dp to keep path strings compact\nfunction r(n: number): string {\n return n.toFixed(3);\n}\n\nfunction SatisfactionFace({ value }: { value: number }) {\n const v = Math.min(100, Math.max(0, value));\n const l = (a: number, b: number) => lerp(a, b, v);\n\n // ── Main mouth arc (cubic bezier) ────────────────────────────────────────\n // Sad: M27.04,105.66 C46.23,58.95 104.46,53.19 135.18,94.15\n // Happy: M27.04,71.83 C46.23,118.54 104.46,124.30 135.18,83.35\n const arc_My = l(105.664, 71.8327);\n const arc_C1y = l(58.9536, 118.543);\n const arc_C2y = l(53.1949, 124.301);\n const arc_Ey = l(94.1461, 83.3503);\n const arcPath = `M27.0389,${r(arc_My)} C46.2348,${r(arc_C1y)} 104.462,${r(arc_C2y)} 135.176,${r(arc_Ey)}`;\n\n // ── Left curl ────────────────────────────────────────────────────────────\n // Sad: M34.10,107.98 C29.94,110.06 22.98,106.60 21.14,101.61\n // Happy: M34.10,69.51 C29.94,67.43 22.98,70.90 21.14,75.89\n const lc_My = l(107.983, 69.5134);\n const lc_C1y = l(110.062, 67.4345);\n const lc_C2y = l(106.596, 70.9);\n const lc_Ey = l(101.606, 75.8902);\n const leftCurl = `M34.0956,${r(lc_My)} C29.9417,${r(lc_C1y)} 22.9833,${r(lc_C2y)} 21.1386,${r(lc_Ey)}`;\n\n // ── Right curl ───────────────────────────────────────────────────────────\n // Sad: M138.97,88.07 C139.90,92.62 134.75,98.44 129.45,98.93\n // Happy: M138.97,89.43 C139.90,84.88 134.75,79.05 129.45,78.56\n const rc_My = l(88.0696, 89.4267);\n const rc_C1y = l(92.62, 84.8763);\n const rc_C2y = l(98.4444, 79.0519);\n const rc_Ey = l(98.9347, 78.5616);\n const rightCurl = `M138.966,${r(rc_My)} C139.899,${r(rc_C1y)} 134.75,${r(rc_C2y)} 129.453,${r(rc_Ey)}`;\n\n const strokeColor = 'hsl(var(--secondary-600))';\n const strokeW = '3.83917';\n\n return (\n <svg\n viewBox=\"0 0 162 131\"\n width={162}\n height={131}\n fill=\"none\"\n aria-hidden=\"true\"\n >\n {/* Left eye */}\n <ellipse cx=\"63.7274\" cy=\"35.3567\" rx=\"6.39862\" ry=\"15.3567\" fill={strokeColor} />\n {/* Right eye */}\n <ellipse cx=\"97.5246\" cy=\"35.3567\" rx=\"6.39862\" ry=\"15.3567\" fill={strokeColor} />\n {/* Main mouth arc */}\n <path d={arcPath} stroke={strokeColor} strokeWidth={strokeW} />\n {/* Left curl */}\n <path d={leftCurl} stroke={strokeColor} strokeWidth={strokeW} strokeLinecap=\"round\" />\n {/* Right curl */}\n <path d={rightCurl} stroke={strokeColor} strokeWidth={strokeW} strokeLinecap=\"round\" />\n </svg>\n );\n}\n\n// ─── CustomerSatisfaction ─────────────────────────────────────────────────────\n\nexport function CustomerSatisfaction({\n className,\n question = 'Quanto sei soddisfatto della tua esperienza?',\n minLabelLines = ['PER NIENTE', 'SODDISFATTO'],\n maxLabelLines = ['MOLTO', 'SODDISFATTO'],\n defaultValue = 25,\n value: controlledValue,\n onChange,\n}: CustomerSatisfactionProps) {\n const sliderId = useId();\n const [internalValue, setInternalValue] = useState(defaultValue);\n\n const value = controlledValue ?? internalValue;\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const v = Number(e.target.value);\n setInternalValue(v);\n onChange?.(v);\n };\n\n // Thumb position as a percentage for custom styling\n const pct = value;\n\n return (\n <div\n className={cn(\n 'flex flex-col gap-[var(--spacing-
|
|
58
|
+
"content": "'use client';\n\nimport React, { useState, useId } from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface CustomerSatisfactionProps {\n className?: string;\n /** Question text shown as the card heading */\n question?: string;\n /** Min slider label (left) */\n minLabel?: string;\n /** Max slider label (right) */\n maxLabel?: string;\n /** Initial value 0–100 */\n defaultValue?: number;\n /** Controlled value 0–100 */\n value?: number;\n onChange?: (value: number) => void;\n /** Left-side label (two lines, e.g. [\"PER NIENTE\", \"SODDISFATTO\"]) */\n minLabelLines?: [string, string];\n /** Right-side label (two lines) */\n maxLabelLines?: [string, string];\n}\n\n// ─── Face illustration (pure SVG) ────────────────────────────────────────────\n// Exact coordinates extracted from Figma SVG exports.\n// Viewbox: 0 0 162 131 (matches both exported SVGs)\n//\n// Frame 4254 = Sad (v = 0)\n// Frame 4255 = Happy (v = 100)\n//\n// All numeric path coordinates are linearly interpolated so the face\n// morphs smoothly as the slider moves.\n\n// Helper: lerp between sad (a) and happy (b) values based on 0–100 slider\nfunction lerp(a: number, b: number, t: number): number {\n return a + (b - a) * (t / 100);\n}\n\n// Round to 3 dp to keep path strings compact\nfunction r(n: number): string {\n return n.toFixed(3);\n}\n\nfunction SatisfactionFace({ value }: { value: number }) {\n const v = Math.min(100, Math.max(0, value));\n const l = (a: number, b: number) => lerp(a, b, v);\n\n // ── Main mouth arc (cubic bezier) ────────────────────────────────────────\n // Sad: M27.04,105.66 C46.23,58.95 104.46,53.19 135.18,94.15\n // Happy: M27.04,71.83 C46.23,118.54 104.46,124.30 135.18,83.35\n const arc_My = l(105.664, 71.8327);\n const arc_C1y = l(58.9536, 118.543);\n const arc_C2y = l(53.1949, 124.301);\n const arc_Ey = l(94.1461, 83.3503);\n const arcPath = `M27.0389,${r(arc_My)} C46.2348,${r(arc_C1y)} 104.462,${r(arc_C2y)} 135.176,${r(arc_Ey)}`;\n\n // ── Left curl ────────────────────────────────────────────────────────────\n // Sad: M34.10,107.98 C29.94,110.06 22.98,106.60 21.14,101.61\n // Happy: M34.10,69.51 C29.94,67.43 22.98,70.90 21.14,75.89\n const lc_My = l(107.983, 69.5134);\n const lc_C1y = l(110.062, 67.4345);\n const lc_C2y = l(106.596, 70.9);\n const lc_Ey = l(101.606, 75.8902);\n const leftCurl = `M34.0956,${r(lc_My)} C29.9417,${r(lc_C1y)} 22.9833,${r(lc_C2y)} 21.1386,${r(lc_Ey)}`;\n\n // ── Right curl ───────────────────────────────────────────────────────────\n // Sad: M138.97,88.07 C139.90,92.62 134.75,98.44 129.45,98.93\n // Happy: M138.97,89.43 C139.90,84.88 134.75,79.05 129.45,78.56\n const rc_My = l(88.0696, 89.4267);\n const rc_C1y = l(92.62, 84.8763);\n const rc_C2y = l(98.4444, 79.0519);\n const rc_Ey = l(98.9347, 78.5616);\n const rightCurl = `M138.966,${r(rc_My)} C139.899,${r(rc_C1y)} 134.75,${r(rc_C2y)} 129.453,${r(rc_Ey)}`;\n\n const strokeColor = 'hsl(var(--secondary-600))';\n const strokeW = '3.83917';\n\n return (\n <svg\n viewBox=\"0 0 162 131\"\n width={162}\n height={131}\n fill=\"none\"\n aria-hidden=\"true\"\n >\n {/* Left eye */}\n <ellipse cx=\"63.7274\" cy=\"35.3567\" rx=\"6.39862\" ry=\"15.3567\" fill={strokeColor} />\n {/* Right eye */}\n <ellipse cx=\"97.5246\" cy=\"35.3567\" rx=\"6.39862\" ry=\"15.3567\" fill={strokeColor} />\n {/* Main mouth arc */}\n <path d={arcPath} stroke={strokeColor} strokeWidth={strokeW} />\n {/* Left curl */}\n <path d={leftCurl} stroke={strokeColor} strokeWidth={strokeW} strokeLinecap=\"round\" />\n {/* Right curl */}\n <path d={rightCurl} stroke={strokeColor} strokeWidth={strokeW} strokeLinecap=\"round\" />\n </svg>\n );\n}\n\n// ─── CustomerSatisfaction ─────────────────────────────────────────────────────\n\nexport function CustomerSatisfaction({\n className,\n question = 'Quanto sei soddisfatto della tua esperienza?',\n minLabelLines = ['PER NIENTE', 'SODDISFATTO'],\n maxLabelLines = ['MOLTO', 'SODDISFATTO'],\n defaultValue = 25,\n value: controlledValue,\n onChange,\n}: CustomerSatisfactionProps) {\n const sliderId = useId();\n const [internalValue, setInternalValue] = useState(defaultValue);\n\n const value = controlledValue ?? internalValue;\n\n const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n const v = Number(e.target.value);\n setInternalValue(v);\n onChange?.(v);\n };\n\n // Thumb position as a percentage for custom styling\n const pct = value;\n\n return (\n <div\n className={cn(\n 'flex flex-col gap-[var(--spacing-xs2,24px)] items-center overflow-hidden rounded-[var(--radius-token-lg,32px)] p-[var(--spacing-xs2,24px)] w-[353px] bg-white',\n className\n )}\n >\n {/* ── Question ── */}\n <p className=\"text-[length:var(--text-body,16px)] leading-[var(--leading-body,24px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] w-full whitespace-pre-wrap\">\n {question}\n </p>\n\n {/* ── Face illustration ── */}\n <div className=\"flex flex-col gap-[var(--spacing-xs3,16px)] items-center p-[var(--spacing-xs2,24px)] shrink-0\">\n <SatisfactionFace value={value} />\n </div>\n\n {/* ── Slider ── */}\n <div className=\"flex flex-col gap-[var(--spacing-xs3,16px)] items-center w-full shrink-0\">\n {/* Track + thumb */}\n <div className=\"relative w-full flex items-center h-[30px]\">\n {/* Track background */}\n <div className=\"absolute inset-x-0 h-[4px] rounded-full bg-[hsl(var(--neutral-200))]\" />\n\n {/* Native range input — fully transparent, sits on top */}\n <label htmlFor={sliderId} className=\"sr-only\">\n {question}\n </label>\n <input\n id={sliderId}\n type=\"range\"\n min={0}\n max={100}\n step={1}\n value={value}\n onChange={handleChange}\n className=\"absolute inset-x-0 w-full h-full opacity-0 cursor-grab active:cursor-grabbing z-10\"\n style={{ margin: 0 }}\n />\n\n {/* Custom thumb */}\n <div\n className=\"absolute size-[30px] rounded-full bg-[hsl(var(--neutral-950))] shadow-md pointer-events-none -translate-x-1/2\"\n style={{ left: `${pct}%` }}\n />\n </div>\n\n {/* Labels */}\n <div className=\"flex items-start justify-between w-full\">\n <p className=\"text-[10px] leading-[1.4] font-normal uppercase tracking-[0.04em] text-[hsl(var(--neutral-500))] whitespace-pre-wrap\">\n {minLabelLines[0]}{'\\n'}{minLabelLines[1]}\n </p>\n <p className=\"text-[10px] leading-[1.4] font-normal uppercase tracking-[0.04em] text-[hsl(var(--neutral-500))] text-right whitespace-pre-wrap\">\n {maxLabelLines[0]}{'\\n'}{maxLabelLines[1]}\n </p>\n </div>\n </div>\n </div>\n );\n}\n\nexport default CustomerSatisfaction;\n"
|
|
59
59
|
}
|
|
60
60
|
]
|
|
61
61
|
}
|
|
@@ -55,12 +55,12 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-radio-button.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type RadioButtonState = 'default' | 'selected';\n\nexport interface RadioButtonProps {\n className?: string;\n state?: RadioButtonState;\n label?: string;\n showLabel?: boolean;\n /** Whether to apply backdrop-blur on default state (used when overlaid on images) */\n blur?: boolean;\n onClick?: () => void;\n}\n\n// ─── Radio dot indicator ──────────────────────────────────────────────────────\n// 24×24 container — outer ring + inner fill dot (only when selected)\nfunction RadioDot({ selected }: { selected: boolean }) {\n return (\n <span className=\"relative inline-flex items-center justify-center size-6 shrink-0\">\n {/* Outer ring */}\n <span\n className={cn(\n 'absolute size-5 rounded-full border-2 transition-colors',\n selected\n ? 'border-[hsl(var(--secondary-600))]'\n : 'border-[hsl(var(--neutral-400))]'\n )}\n />\n {/* Inner fill — only when selected */}\n {selected && (\n <span className=\"absolute size-[10px] rounded-full bg-[hsl(var(--secondary-600))]\" />\n )}\n </span>\n );\n}\n\n// ─── RadioButton ──────────────────────────────────────────────────────────────\n\nexport function RadioButton({\n className,\n state = 'default',\n label = 'LABEL',\n showLabel = true,\n blur = false,\n onClick,\n}: RadioButtonProps) {\n const isSelected = state === 'selected';\n\n return (\n <button\n type=\"button\"\n aria-pressed={isSelected}\n onClick={onClick}\n className={cn(\n 'inline-flex items-center gap-[var(--spacing-
|
|
58
|
+
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type RadioButtonState = 'default' | 'selected';\n\nexport interface RadioButtonProps {\n className?: string;\n state?: RadioButtonState;\n label?: string;\n showLabel?: boolean;\n /** Whether to apply backdrop-blur on default state (used when overlaid on images) */\n blur?: boolean;\n onClick?: () => void;\n}\n\n// ─── Radio dot indicator ──────────────────────────────────────────────────────\n// 24×24 container — outer ring + inner fill dot (only when selected)\nfunction RadioDot({ selected }: { selected: boolean }) {\n return (\n <span className=\"relative inline-flex items-center justify-center size-6 shrink-0\">\n {/* Outer ring */}\n <span\n className={cn(\n 'absolute size-5 rounded-full border-2 transition-colors',\n selected\n ? 'border-[hsl(var(--secondary-600))]'\n : 'border-[hsl(var(--neutral-400))]'\n )}\n />\n {/* Inner fill — only when selected */}\n {selected && (\n <span className=\"absolute size-[10px] rounded-full bg-[hsl(var(--secondary-600))]\" />\n )}\n </span>\n );\n}\n\n// ─── RadioButton ──────────────────────────────────────────────────────────────\n\nexport function RadioButton({\n className,\n state = 'default',\n label = 'LABEL',\n showLabel = true,\n blur = false,\n onClick,\n}: RadioButtonProps) {\n const isSelected = state === 'selected';\n\n return (\n <button\n type=\"button\"\n aria-pressed={isSelected}\n onClick={onClick}\n className={cn(\n 'inline-flex items-center gap-[var(--spacing-xs4,8px)] h-8 pl-3 pr-1 py-1 rounded-[16px] bg-white transition-colors shrink-0',\n blur && !isSelected && 'backdrop-blur-[18.5px]',\n className\n )}\n >\n {showLabel && (\n <span\n className={cn(\n 'text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-medium,500)] whitespace-nowrap shrink-0 tracking-[0.02em] uppercase',\n isSelected\n ? 'text-[hsl(var(--secondary-600))]'\n : 'text-[hsl(var(--neutral-950))]'\n )}\n >\n {label}\n </span>\n )}\n <RadioDot selected={isSelected} />\n </button>\n );\n}\n\nexport default RadioButton;\n"
|
|
59
59
|
},
|
|
60
60
|
{
|
|
61
61
|
"path": "components/ui/sapient-multiple-choice-card.tsx",
|
|
62
62
|
"type": "registry:ui",
|
|
63
|
-
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\nimport { RadioButton } from './sapient-radio-button';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type MultipleChoiceCardStyle = 'image' | 'text';\nexport type MultipleChoiceCardSize = 'large' | 'small';\n\nexport interface MultipleChoiceCardProps {\n className?: string;\n style?: MultipleChoiceCardStyle;\n size?: MultipleChoiceCardSize;\n selected?: boolean;\n label?: string;\n /** Image URL — used when style=\"image\" */\n imageSrc?: string;\n imageAlt?: string;\n /** Card title — used when style=\"text\" */\n title?: string;\n /** Card subtitle — used when style=\"text\" */\n subtitle?: string;\n onSelect?: () => void;\n}\n\n// ─── Placeholder image ────────────────────────────────────────────────────────\nconst PLACEHOLDER_IMAGE =\n 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800&q=80';\n\n// ─── MultipleChoiceCard ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceCard({\n className,\n style = 'image',\n size = 'large',\n selected = false,\n label = 'LABEL',\n imageSrc = PLACEHOLDER_IMAGE,\n imageAlt = '',\n title = 'Card Title',\n subtitle,\n onSelect,\n}: MultipleChoiceCardProps) {\n const isLarge = size === 'large';\n const isSmall = size === 'small';\n const isImage = style === 'image';\n const isText = style === 'text';\n\n const defaultSubtitle = isLarge\n ? 'Subtitle on a single line of text'\n : 'Subtitle on two\\nlines of text';\n\n const resolvedSubtitle = subtitle ?? defaultSubtitle;\n\n // ── Image Large: 353×141, landscape full-bleed, radio bottom-right ────────\n if (isImage && isLarge) {\n return (\n <div\n className={cn(\n 'relative flex flex-col items-end justify-end overflow-hidden rounded-[var(--radius-token-lg,32px)] w-[353px] h-[141px] p-[var(--spacing-
|
|
63
|
+
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\nimport { RadioButton } from './sapient-radio-button';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type MultipleChoiceCardStyle = 'image' | 'text';\nexport type MultipleChoiceCardSize = 'large' | 'small';\n\nexport interface MultipleChoiceCardProps {\n className?: string;\n style?: MultipleChoiceCardStyle;\n size?: MultipleChoiceCardSize;\n selected?: boolean;\n label?: string;\n /** Image URL — used when style=\"image\" */\n imageSrc?: string;\n imageAlt?: string;\n /** Card title — used when style=\"text\" */\n title?: string;\n /** Card subtitle — used when style=\"text\" */\n subtitle?: string;\n onSelect?: () => void;\n}\n\n// ─── Placeholder image ────────────────────────────────────────────────────────\nconst PLACEHOLDER_IMAGE =\n 'https://images.unsplash.com/photo-1519681393784-d120267933ba?w=800&q=80';\n\n// ─── MultipleChoiceCard ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceCard({\n className,\n style = 'image',\n size = 'large',\n selected = false,\n label = 'LABEL',\n imageSrc = PLACEHOLDER_IMAGE,\n imageAlt = '',\n title = 'Card Title',\n subtitle,\n onSelect,\n}: MultipleChoiceCardProps) {\n const isLarge = size === 'large';\n const isSmall = size === 'small';\n const isImage = style === 'image';\n const isText = style === 'text';\n\n const defaultSubtitle = isLarge\n ? 'Subtitle on a single line of text'\n : 'Subtitle on two\\nlines of text';\n\n const resolvedSubtitle = subtitle ?? defaultSubtitle;\n\n // ── Image Large: 353×141, landscape full-bleed, radio bottom-right ────────\n if (isImage && isLarge) {\n return (\n <div\n className={cn(\n 'relative flex flex-col items-end justify-end overflow-hidden rounded-[var(--radius-token-lg,32px)] w-[353px] h-[141px] p-[var(--spacing-xs3,16px)] cursor-pointer',\n className\n )}\n onClick={onSelect}\n >\n {/* Full-bleed image */}\n <img\n src={imageSrc}\n alt={imageAlt}\n className=\"absolute inset-0 w-full h-full object-cover pointer-events-none rounded-[var(--radius-token-lg,32px)]\"\n />\n {/* Dark overlay */}\n <div className=\"absolute inset-0 bg-black/15 rounded-[var(--radius-token-lg,32px)] pointer-events-none\" />\n {/* Radio pill — bottom-right */}\n <RadioButton\n state={selected ? 'selected' : 'default'}\n label={label}\n blur={!selected}\n className=\"relative z-10\"\n />\n </div>\n );\n }\n\n // ── Image Small: 170×209, portrait full-bleed, radio bottom full-width ────\n if (isImage && isSmall) {\n return (\n <div\n className={cn(\n 'relative flex flex-col items-start justify-end overflow-hidden rounded-[var(--radius-token-lg,32px)] w-[170px] h-[209px] p-[var(--spacing-xs3,16px)] cursor-pointer',\n className\n )}\n onClick={onSelect}\n >\n {/* Full-bleed image */}\n <img\n src={imageSrc}\n alt={imageAlt}\n className=\"absolute inset-0 w-full h-full object-cover pointer-events-none rounded-[var(--radius-token-lg,32px)]\"\n />\n {/* Dark overlay */}\n <div className=\"absolute inset-0 bg-black/15 rounded-[var(--radius-token-lg,32px)] pointer-events-none\" />\n {/* Radio pill — bottom, full width */}\n <RadioButton\n state={selected ? 'selected' : 'default'}\n label={label}\n blur={!selected}\n className=\"relative z-10 w-full justify-between\"\n />\n </div>\n );\n }\n\n // ── Text Large: 353×141, white bg, radio top-right, title+subtitle bottom ─\n if (isText && isLarge) {\n return (\n <div\n className={cn(\n 'relative flex flex-col items-end justify-between overflow-hidden rounded-[var(--radius-token-lg,32px)] w-[353px] h-[141px] p-[var(--spacing-xs3,16px)] bg-[hsl(var(--neutral-50))] cursor-pointer',\n className\n )}\n onClick={onSelect}\n >\n {/* Radio dot only — top-right, no label */}\n <RadioButton\n state={selected ? 'selected' : 'default'}\n showLabel={false}\n className=\"bg-transparent p-0 h-auto\"\n />\n {/* Title + subtitle — bottom-left */}\n <div className=\"flex flex-col gap-[var(--spacing-xs5,4px)] items-start w-full p-[var(--spacing-xs4,8px)]\">\n <p className=\"text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] w-full whitespace-pre-wrap\">\n {title}\n </p>\n <p className=\"text-[16px] leading-[var(--leading-body,24px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-500))] w-full overflow-hidden text-ellipsis whitespace-nowrap\">\n {resolvedSubtitle}\n </p>\n </div>\n </div>\n );\n }\n\n // ── Text Small: 170×209, white bg, radio top-right, title+subtitle bottom ─\n return (\n <div\n className={cn(\n 'relative flex flex-col items-end justify-between overflow-hidden rounded-[var(--radius-token-lg,32px)] w-[170px] h-[209px] p-[var(--spacing-xs3,16px)] bg-[hsl(var(--neutral-50))] cursor-pointer',\n className\n )}\n onClick={onSelect}\n >\n {/* Radio dot only — top-right */}\n <RadioButton\n state={selected ? 'selected' : 'default'}\n showLabel={false}\n className=\"bg-transparent p-0 h-auto\"\n />\n {/* Title + subtitle — bottom-left */}\n <div className=\"flex flex-col gap-[var(--spacing-xs5,4px)] items-start w-full p-[var(--spacing-xs4,8px)]\">\n <p className=\"text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] w-full whitespace-pre-wrap\">\n {title}\n </p>\n <p className=\"text-[16px] leading-[var(--leading-body,24px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-500))] w-full whitespace-pre-wrap\">\n {resolvedSubtitle}\n </p>\n </div>\n </div>\n );\n}\n\nexport default MultipleChoiceCard;\n"
|
|
64
64
|
}
|
|
65
65
|
]
|
|
66
66
|
}
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-multiple-choice-grid.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "'use client';\n\nimport React, { useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { MultipleChoiceCard } from './sapient-multiple-choice-card';\nimport type { MultipleChoiceOption } from './sapient-multiple-choice-list';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface MultipleChoiceGridProps {\n className?: string;\n /** Options for left column (top-aligned) */\n leftOptions?: MultipleChoiceOption[];\n /** Options for right column (offset down by 40px) */\n rightOptions?: MultipleChoiceOption[];\n /** Initially selected option id */\n defaultSelected?: string;\n onChange?: (selectedId: string) => void;\n}\n\n// ─── Default options ──────────────────────────────────────────────────────────\nconst DEFAULT_LEFT: MultipleChoiceOption[] = [\n { id: '1', label: 'LABEL' },\n { id: '3', label: 'LABEL' },\n];\nconst DEFAULT_RIGHT: MultipleChoiceOption[] = [\n { id: '2', label: 'LABEL' },\n { id: '4', label: 'LABEL' },\n];\n\n// ─── MultipleChoiceGrid ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceGrid({\n className,\n leftOptions = DEFAULT_LEFT,\n rightOptions = DEFAULT_RIGHT,\n defaultSelected,\n onChange,\n}: MultipleChoiceGridProps) {\n // Default selects the first right-column item (matches Figma — right col first card selected)\n const [selected, setSelected] = useState<string | undefined>(\n defaultSelected ?? rightOptions[0]?.id\n );\n\n const handleSelect = (id: string) => {\n setSelected(id);\n onChange?.(id);\n };\n\n return (\n <div\n className={cn(\n 'flex gap-[var(--spacing-
|
|
58
|
+
"content": "'use client';\n\nimport React, { useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { MultipleChoiceCard } from './sapient-multiple-choice-card';\nimport type { MultipleChoiceOption } from './sapient-multiple-choice-list';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface MultipleChoiceGridProps {\n className?: string;\n /** Options for left column (top-aligned) */\n leftOptions?: MultipleChoiceOption[];\n /** Options for right column (offset down by 40px) */\n rightOptions?: MultipleChoiceOption[];\n /** Initially selected option id */\n defaultSelected?: string;\n onChange?: (selectedId: string) => void;\n}\n\n// ─── Default options ──────────────────────────────────────────────────────────\nconst DEFAULT_LEFT: MultipleChoiceOption[] = [\n { id: '1', label: 'LABEL' },\n { id: '3', label: 'LABEL' },\n];\nconst DEFAULT_RIGHT: MultipleChoiceOption[] = [\n { id: '2', label: 'LABEL' },\n { id: '4', label: 'LABEL' },\n];\n\n// ─── MultipleChoiceGrid ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceGrid({\n className,\n leftOptions = DEFAULT_LEFT,\n rightOptions = DEFAULT_RIGHT,\n defaultSelected,\n onChange,\n}: MultipleChoiceGridProps) {\n // Default selects the first right-column item (matches Figma — right col first card selected)\n const [selected, setSelected] = useState<string | undefined>(\n defaultSelected ?? rightOptions[0]?.id\n );\n\n const handleSelect = (id: string) => {\n setSelected(id);\n onChange?.(id);\n };\n\n return (\n <div\n className={cn(\n 'flex gap-[var(--spacing-xs3,16px)] items-start',\n className\n )}\n >\n {/* Left column — starts at top */}\n <div className=\"flex flex-col gap-[var(--spacing-xs3,16px)] items-start w-[170px]\">\n {leftOptions.map((option) => (\n <MultipleChoiceCard\n key={option.id}\n style=\"image\"\n size=\"small\"\n selected={selected === option.id}\n label={option.label}\n imageSrc={option.imageSrc}\n imageAlt={option.imageAlt}\n onSelect={() => handleSelect(option.id)}\n className=\"w-full\"\n />\n ))}\n </div>\n\n {/* Right column — offset down by 40px (masonry effect from Figma) */}\n <div className=\"flex flex-col gap-[var(--spacing-xs3,16px)] items-start w-[170px] pt-[var(--spacing-s,40px)]\">\n {rightOptions.map((option) => (\n <MultipleChoiceCard\n key={option.id}\n style=\"image\"\n size=\"small\"\n selected={selected === option.id}\n label={option.label}\n imageSrc={option.imageSrc}\n imageAlt={option.imageAlt}\n onSelect={() => handleSelect(option.id)}\n className=\"w-full\"\n />\n ))}\n </div>\n </div>\n );\n}\n\nexport default MultipleChoiceGrid;\n"
|
|
59
59
|
}
|
|
60
60
|
],
|
|
61
61
|
"registryDependencies": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-multiple-choice-list.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "'use client';\n\nimport React, { useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { MultipleChoiceCard } from './sapient-multiple-choice-card';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface MultipleChoiceOption {\n id: string;\n label?: string;\n imageSrc?: string;\n imageAlt?: string;\n title?: string;\n subtitle?: string;\n}\n\nexport interface MultipleChoiceListProps {\n className?: string;\n options?: MultipleChoiceOption[];\n /** Initially selected option id */\n defaultSelected?: string;\n onChange?: (selectedId: string) => void;\n}\n\n// ─── Default options ──────────────────────────────────────────────────────────\nconst DEFAULT_OPTIONS: MultipleChoiceOption[] = [\n { id: '1', label: 'LABEL' },\n { id: '2', label: 'LABEL' },\n { id: '3', label: 'LABEL' },\n];\n\n// ─── MultipleChoiceList ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceList({\n className,\n options = DEFAULT_OPTIONS,\n defaultSelected,\n onChange,\n}: MultipleChoiceListProps) {\n const [selected, setSelected] = useState<string | undefined>(\n defaultSelected ?? options[0]?.id\n );\n\n const handleSelect = (id: string) => {\n setSelected(id);\n onChange?.(id);\n };\n\n return (\n <div\n className={cn(\n 'flex flex-col gap-[var(--spacing-
|
|
58
|
+
"content": "'use client';\n\nimport React, { useState } from 'react';\nimport { cn } from '@/lib/utils';\nimport { MultipleChoiceCard } from './sapient-multiple-choice-card';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport interface MultipleChoiceOption {\n id: string;\n label?: string;\n imageSrc?: string;\n imageAlt?: string;\n title?: string;\n subtitle?: string;\n}\n\nexport interface MultipleChoiceListProps {\n className?: string;\n options?: MultipleChoiceOption[];\n /** Initially selected option id */\n defaultSelected?: string;\n onChange?: (selectedId: string) => void;\n}\n\n// ─── Default options ──────────────────────────────────────────────────────────\nconst DEFAULT_OPTIONS: MultipleChoiceOption[] = [\n { id: '1', label: 'LABEL' },\n { id: '2', label: 'LABEL' },\n { id: '3', label: 'LABEL' },\n];\n\n// ─── MultipleChoiceList ───────────────────────────────────────────────────────\n\nexport function MultipleChoiceList({\n className,\n options = DEFAULT_OPTIONS,\n defaultSelected,\n onChange,\n}: MultipleChoiceListProps) {\n const [selected, setSelected] = useState<string | undefined>(\n defaultSelected ?? options[0]?.id\n );\n\n const handleSelect = (id: string) => {\n setSelected(id);\n onChange?.(id);\n };\n\n return (\n <div\n className={cn(\n 'flex flex-col gap-[var(--spacing-xs3,16px)] items-start w-[353px]',\n className\n )}\n >\n {options.map((option) => (\n <MultipleChoiceCard\n key={option.id}\n style=\"image\"\n size=\"large\"\n selected={selected === option.id}\n label={option.label}\n imageSrc={option.imageSrc}\n imageAlt={option.imageAlt}\n onSelect={() => handleSelect(option.id)}\n className=\"w-full\"\n />\n ))}\n </div>\n );\n}\n\nexport default MultipleChoiceList;\n"
|
|
59
59
|
}
|
|
60
60
|
],
|
|
61
61
|
"registryDependencies": [
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
{
|
|
56
56
|
"path": "components/ui/sapient-news-card.tsx",
|
|
57
57
|
"type": "registry:ui",
|
|
58
|
-
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type NewsCardOrientation = 'vertical' | 'horizontal';\nexport type NewsCardState = 'enabled' | 'hover';\n\nexport interface NewsCardProps {\n className?: string;\n /** Image URL for the card thumbnail */\n imageSrc?: string;\n imageAlt?: string;\n /** Small badge shown above roofline. Violet pill with a live \"blink\" dot. */\n showBadge?: boolean;\n badgeLabel?: string;\n /** Category / section label above the headline */\n roofline?: string;\n headline?: string;\n showBody?: boolean;\n body?: string;\n datePosted?: string;\n /** Wraps content in a white card surface with rounded corners */\n background?: boolean;\n orientation?: NewsCardOrientation;\n state?: NewsCardState;\n}\n\n// ─── Blink Badge ──────────────────────────────────────────────────────────────\n// Matches the Figma \"Badge\" – violet pill with animated dot + label text.\nfunction BlinkBadge({ label = 'Label' }: { label?: string }) {\n return (\n <div className=\"inline-flex items-center gap-[var(--spacing-
|
|
58
|
+
"content": "'use client';\n\nimport React from 'react';\nimport { cn } from '@/lib/utils';\n\n// ─── Types ────────────────────────────────────────────────────────────────────\n\nexport type NewsCardOrientation = 'vertical' | 'horizontal';\nexport type NewsCardState = 'enabled' | 'hover';\n\nexport interface NewsCardProps {\n className?: string;\n /** Image URL for the card thumbnail */\n imageSrc?: string;\n imageAlt?: string;\n /** Small badge shown above roofline. Violet pill with a live \"blink\" dot. */\n showBadge?: boolean;\n badgeLabel?: string;\n /** Category / section label above the headline */\n roofline?: string;\n headline?: string;\n showBody?: boolean;\n body?: string;\n datePosted?: string;\n /** Wraps content in a white card surface with rounded corners */\n background?: boolean;\n orientation?: NewsCardOrientation;\n state?: NewsCardState;\n}\n\n// ─── Blink Badge ──────────────────────────────────────────────────────────────\n// Matches the Figma \"Badge\" – violet pill with animated dot + label text.\nfunction BlinkBadge({ label = 'Label' }: { label?: string }) {\n return (\n <div className=\"inline-flex items-center gap-[var(--spacing-xs5,4px)] h-5 px-[var(--spacing-xs4,8px)] rounded-[var(--radius-token-full,100px)] bg-[hsl(var(--secondary-600))] shrink-0\">\n {/* Blink dot */}\n <span className=\"relative flex items-center justify-center size-3 shrink-0\">\n <span className=\"absolute inline-flex size-[9.6px] rounded-full bg-[hsl(var(--secondary-300))] opacity-75 animate-ping\" />\n <span className=\"relative inline-flex size-[4px] rounded-full bg-white\" />\n </span>\n <span className=\"text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-regular,400)] text-white whitespace-nowrap\">\n {label}\n </span>\n </div>\n );\n}\n\n// ─── Card Image ───────────────────────────────────────────────────────────────\nfunction CardImage({\n src,\n alt = '',\n className,\n}: {\n src: string;\n alt?: string;\n className?: string;\n}) {\n return (\n <div className={cn('relative overflow-hidden shrink-0', className)}>\n <img\n src={src}\n alt={alt}\n className=\"absolute inset-0 w-full h-full object-cover\"\n />\n </div>\n );\n}\n\n// ─── NewsCard ─────────────────────────────────────────────────────────────────\n\nconst PLACEHOLDER_IMAGE =\n 'https://images.unsplash.com/photo-1469474968028-56623f02e42e?w=600&q=80';\n\nexport function NewsCard({\n className,\n imageSrc = PLACEHOLDER_IMAGE,\n imageAlt = '',\n showBadge = true,\n badgeLabel = 'Label',\n roofline = 'Roofline',\n headline = 'Headline lorem ipsum dolor sit amet, consectetur adipiscing elit',\n showBody = true,\n body = 'Body lorem ipsum dolor sit amet, consectetur adipiscing elit',\n datePosted = '1 Jan 2026',\n background = false,\n orientation = 'vertical',\n state = 'enabled',\n}: NewsCardProps) {\n const isHover = state === 'hover';\n const isVertical = orientation === 'vertical';\n const isHorizontal = orientation === 'horizontal';\n\n // Headline color: teal accent on hover, neutral-950 on enabled\n const headlineColor = isHover\n ? 'text-[hsl(var(--foreground-strong))]'\n : 'text-[hsl(var(--neutral-950))]';\n\n // ── VERTICAL ─────────────────────────────────────────────────────────────\n if (isVertical) {\n return (\n <div\n className={cn(\n 'flex flex-col items-start w-[256px]',\n background && 'overflow-hidden rounded-[var(--radius-token-lg,32px)]',\n className\n )}\n >\n {/* Image – full width, rounded top if no background, fully clipped if background */}\n <CardImage\n src={imageSrc}\n alt={imageAlt}\n className={cn(\n 'w-full h-[192px]',\n !background && 'rounded-[var(--radius-token-lg,32px)]'\n )}\n />\n\n {/* Text container */}\n <div\n className={cn(\n 'flex flex-col items-start w-full shrink-0',\n background\n ? 'bg-white gap-[var(--spacing-xs3,16px)] p-[var(--spacing-xs3,16px)]'\n : 'gap-[var(--spacing-xs3,16px)] py-[var(--spacing-xs3,16px)]'\n )}\n >\n {/* Subtitle row: badge + roofline */}\n <div className=\"flex flex-col items-start gap-[var(--spacing-xs4,8px)] w-full shrink-0\">\n {showBadge && <BlinkBadge label={badgeLabel} />}\n <p\n className={cn(\n 'text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] w-full shrink-0 whitespace-pre-wrap',\n background\n ? 'font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))]'\n : 'font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))]'\n )}\n >\n {roofline}\n </p>\n </div>\n\n {/* Headline + body */}\n <div className=\"flex flex-col gap-[var(--spacing-xs4,8px)] items-start w-full shrink-0\">\n <p\n className={cn(\n 'text-[length:var(--text-subheading,20px)] leading-[var(--leading-subheading,24px)] font-[var(--font-weight-medium,500)] w-full overflow-hidden',\n headlineColor\n )}\n >\n {headline}\n </p>\n {showBody && (\n <p className=\"text-[length:var(--text-body-small,14px)] leading-[var(--leading-body-small,22px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-600))] max-h-[44px] overflow-hidden w-full\">\n {body}\n </p>\n )}\n </div>\n\n {/* Date */}\n <p className=\"text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-950))] w-full whitespace-pre-wrap shrink-0\">\n {datePosted}\n </p>\n </div>\n </div>\n );\n }\n\n // ── HORIZONTAL ───────────────────────────────────────────────────────────\n return (\n <div\n className={cn(\n 'flex items-stretch w-[441px]',\n background\n ? 'overflow-hidden rounded-[var(--radius-token-md,16px)]'\n : 'flex-row',\n // For non-background enabled: image on right, row layout\n // For hover / background: always row\n className\n )}\n >\n {/* Text container – left side */}\n <div\n className={cn(\n 'flex flex-col flex-1 min-w-0 gap-[var(--spacing-xs4,8px)] justify-center py-[var(--spacing-xs4,8px)]',\n background\n ? 'bg-white px-[var(--spacing-xs3,16px)]'\n : 'pr-[var(--spacing-xs3,16px)]'\n )}\n >\n {/* Subtitle row: badge + roofline inline */}\n <div className=\"flex items-center gap-[var(--spacing-xs4,8px)] w-full shrink-0\">\n {showBadge && <BlinkBadge label={badgeLabel} />}\n <p className=\"flex-1 min-w-0 text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-medium,500)] text-[hsl(var(--neutral-950))] overflow-hidden text-ellipsis whitespace-pre-wrap max-h-5\">\n {roofline}\n </p>\n </div>\n\n {/* Headline */}\n <p\n className={cn(\n 'text-[length:var(--text-body-small,14px)] leading-[var(--leading-body-small,22px)] font-[var(--font-weight-medium,500)] overflow-hidden text-ellipsis w-full whitespace-pre-wrap shrink-0',\n headlineColor\n )}\n >\n {headline}\n </p>\n\n {/* Date */}\n <p className=\"text-[length:var(--text-label,12px)] leading-[var(--leading-label,18px)] font-[var(--font-weight-regular,400)] text-[hsl(var(--neutral-950))] w-full whitespace-pre-wrap shrink-0\">\n {datePosted}\n </p>\n </div>\n\n {/* Image – right side, fixed size */}\n <CardImage\n src={imageSrc}\n alt={imageAlt}\n className={cn(\n 'w-[160px] h-[116px] shrink-0',\n !background && 'rounded-[var(--radius-token-sm,8px)]'\n )}\n />\n </div>\n );\n}\n\nexport default NewsCard;\n"
|
|
59
59
|
}
|
|
60
60
|
]
|
|
61
61
|
}
|