rafters 0.3.1 → 0.4.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/dist/index.js +158 -65
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -20309,6 +20309,11 @@ var MOTION_NAMESPACE_PROPERTY = {
|
|
|
20309
20309
|
};
|
|
20310
20310
|
var MOTION_NAMESPACE_NAMES = Object.keys(MOTION_NAMESPACE_PROPERTY);
|
|
20311
20311
|
var REDUCED_MOTION_ZEROED = /* @__PURE__ */ new Set(["duration", "delay"]);
|
|
20312
|
+
var TAILWIND_THEME_PREFIX = {
|
|
20313
|
+
duration: "transition-duration",
|
|
20314
|
+
ease: "ease",
|
|
20315
|
+
delay: "transition-delay"
|
|
20316
|
+
};
|
|
20312
20317
|
var MOTION_NAMESPACE_TOKEN = new RegExp(`^rafters-(${MOTION_NAMESPACE_NAMES.join("|")})-(.+)$`);
|
|
20313
20318
|
function motionNamespaceParts(name) {
|
|
20314
20319
|
const match = MOTION_NAMESPACE_TOKEN.exec(name);
|
|
@@ -20565,6 +20570,11 @@ function generateThemeBlock(groups) {
|
|
|
20565
20570
|
lines.push(bridgeLines);
|
|
20566
20571
|
lines.push("");
|
|
20567
20572
|
}
|
|
20573
|
+
const animationKeys = generateMotionAnimationKeys(groups.motion);
|
|
20574
|
+
if (animationKeys) {
|
|
20575
|
+
lines.push(animationKeys);
|
|
20576
|
+
lines.push("");
|
|
20577
|
+
}
|
|
20568
20578
|
if (groups.breakpoint.length > 0) {
|
|
20569
20579
|
for (const token of groups.breakpoint) {
|
|
20570
20580
|
const value = tokenValueToCSS(token);
|
|
@@ -20591,10 +20601,6 @@ function generateThemeBlock(groups) {
|
|
|
20591
20601
|
}
|
|
20592
20602
|
lines.push("");
|
|
20593
20603
|
}
|
|
20594
|
-
const animationTokens = generateAnimationTokens(groups.motion);
|
|
20595
|
-
if (animationTokens) {
|
|
20596
|
-
lines.push(animationTokens);
|
|
20597
|
-
}
|
|
20598
20604
|
lines.push("}");
|
|
20599
20605
|
return lines.join("\n");
|
|
20600
20606
|
}
|
|
@@ -20685,18 +20691,6 @@ function generateKeyframes(motionTokens) {
|
|
|
20685
20691
|
}
|
|
20686
20692
|
return lines.join("\n").trim();
|
|
20687
20693
|
}
|
|
20688
|
-
function generateAnimationTokens(motionTokens) {
|
|
20689
|
-
const animationTokens = motionTokens.filter((t) => t.name.startsWith("motion-animation-"));
|
|
20690
|
-
if (animationTokens.length === 0) {
|
|
20691
|
-
return "";
|
|
20692
|
-
}
|
|
20693
|
-
const lines = [];
|
|
20694
|
-
for (const token of animationTokens) {
|
|
20695
|
-
const animName = token.animationName || token.name.replace("motion-animation-", "");
|
|
20696
|
-
lines.push(` --animate-${animName}: ${token.value};`);
|
|
20697
|
-
}
|
|
20698
|
-
return lines.join("\n");
|
|
20699
|
-
}
|
|
20700
20694
|
function generateDepthUtilities(depthTokens) {
|
|
20701
20695
|
if (depthTokens.length === 0) return "";
|
|
20702
20696
|
const lines = ["/* Depth (z-index) utilities -- words over numbers */"];
|
|
@@ -20791,8 +20785,8 @@ function generateMotionUtilities(motionTokens) {
|
|
|
20791
20785
|
const className = token.name.replace("motion-semantic-", "motion-");
|
|
20792
20786
|
lines.push(`@utility ${className} {`);
|
|
20793
20787
|
lines.push(` transition-property: ${spec.properties.join(", ")};`);
|
|
20794
|
-
lines.push(` transition-duration: var(--duration-${spec.durationTier});`);
|
|
20795
|
-
lines.push(` transition-timing-function: var(--ease-${spec.curve});`);
|
|
20788
|
+
lines.push(` transition-duration: var(--rafters-duration-${spec.durationTier});`);
|
|
20789
|
+
lines.push(` transition-timing-function: var(--rafters-ease-${spec.curve});`);
|
|
20796
20790
|
if (spec.reducedMotion) {
|
|
20797
20791
|
lines.push(" @media (prefers-reduced-motion: reduce) {");
|
|
20798
20792
|
lines.push(` transition-property: ${spec.reducedMotion.properties.join(", ")};`);
|
|
@@ -20848,7 +20842,7 @@ function parseCellSpec(tokenName, raw) {
|
|
|
20848
20842
|
`tailwind exporter: motion cell token "${tokenName}" has an unrecognized duration.kind ${JSON.stringify(kind ?? null)}. Known duration.kind values: period, tier.`
|
|
20849
20843
|
);
|
|
20850
20844
|
}
|
|
20851
|
-
function
|
|
20845
|
+
function generateMotionAnimationKeys(motionTokens) {
|
|
20852
20846
|
const cellTokens = motionTokens.filter((t) => t.name.startsWith("motion-cell-"));
|
|
20853
20847
|
if (cellTokens.length === 0) return "";
|
|
20854
20848
|
const periodMembers = /* @__PURE__ */ new Set();
|
|
@@ -20856,38 +20850,38 @@ function generateMotionCellUtilities(motionTokens) {
|
|
|
20856
20850
|
const parts = motionNamespaceParts(token.name);
|
|
20857
20851
|
if (parts?.namespace === "period") periodMembers.add(parts.member);
|
|
20858
20852
|
}
|
|
20859
|
-
const
|
|
20860
|
-
"/* Motion cells -- one utility per animated (component, part, transition) */"
|
|
20861
|
-
];
|
|
20853
|
+
const keys = /* @__PURE__ */ new Map();
|
|
20862
20854
|
for (const token of cellTokens) {
|
|
20863
20855
|
if (typeof token.value !== "string") continue;
|
|
20864
20856
|
const spec = parseCellSpec(token.name, token.value);
|
|
20865
|
-
lines.push(`@utility ${token.name.replace("motion-cell-", "animate-")} {`);
|
|
20866
20857
|
if (spec === null) {
|
|
20867
|
-
|
|
20868
|
-
|
|
20858
|
+
keys.set(token.name.replace("motion-cell-", ""), token.value);
|
|
20859
|
+
continue;
|
|
20860
|
+
}
|
|
20861
|
+
if (spec.duration.kind === "period") {
|
|
20869
20862
|
const { period } = spec.duration;
|
|
20870
20863
|
if (!periodMembers.has(period)) {
|
|
20871
20864
|
throw new Error(
|
|
20872
20865
|
`tailwind exporter: motion cell token "${token.name}" references unknown period "${period}". Known periods: ${[...periodMembers].sort().join(", ")}.`
|
|
20873
20866
|
);
|
|
20874
20867
|
}
|
|
20875
|
-
|
|
20876
|
-
|
|
20877
|
-
|
|
20878
|
-
|
|
20879
|
-
|
|
20880
|
-
lines.push(` animation-duration: var(--rafters-duration-${spec.duration.tier});`);
|
|
20881
|
-
lines.push(` animation-timing-function: var(--rafters-ease-${spec.curve});`);
|
|
20882
|
-
}
|
|
20883
|
-
if (token.reducedMotionAware !== false) {
|
|
20884
|
-
lines.push(" @media (prefers-reduced-motion: reduce) {");
|
|
20885
|
-
lines.push(" animation-duration: 0s;");
|
|
20886
|
-
lines.push(" }");
|
|
20868
|
+
keys.set(
|
|
20869
|
+
`${spec.keyframe}-${period}`,
|
|
20870
|
+
`${spec.keyframe} var(--rafters-period-${period}) infinite`
|
|
20871
|
+
);
|
|
20872
|
+
continue;
|
|
20887
20873
|
}
|
|
20888
|
-
|
|
20874
|
+
const { tier } = spec.duration;
|
|
20875
|
+
keys.set(
|
|
20876
|
+
`${spec.keyframe}-${tier}-${spec.curve}`,
|
|
20877
|
+
`${spec.keyframe} var(--rafters-duration-${tier}) var(--rafters-ease-${spec.curve})`
|
|
20878
|
+
);
|
|
20889
20879
|
}
|
|
20890
|
-
return
|
|
20880
|
+
if (keys.size === 0) return "";
|
|
20881
|
+
return [
|
|
20882
|
+
" /* Motion assignments -- one key per distinct (shape, tier, curve) */",
|
|
20883
|
+
...[...keys].map(([name, value]) => ` --animate-${name}: ${value};`)
|
|
20884
|
+
].join("\n");
|
|
20891
20885
|
}
|
|
20892
20886
|
function generateMotionNamespaceVars(motionTokens) {
|
|
20893
20887
|
const lines = [];
|
|
@@ -20903,35 +20897,45 @@ function generateMotionNamespaceVars(motionTokens) {
|
|
|
20903
20897
|
...lines
|
|
20904
20898
|
].join("\n");
|
|
20905
20899
|
}
|
|
20900
|
+
function generateReducedMotionLaw(motionTokens) {
|
|
20901
|
+
const lines = [];
|
|
20902
|
+
for (const token of motionTokens) {
|
|
20903
|
+
const parts = motionNamespaceParts(token.name);
|
|
20904
|
+
if (!parts || !REDUCED_MOTION_ZEROED.has(parts.namespace)) continue;
|
|
20905
|
+
lines.push(` --${token.name}: 0ms;`);
|
|
20906
|
+
}
|
|
20907
|
+
if (lines.length === 0) return "";
|
|
20908
|
+
return [
|
|
20909
|
+
"/* Reduced motion is law: every duration and delay is zero, loops are not. */",
|
|
20910
|
+
"@media (prefers-reduced-motion: reduce) {",
|
|
20911
|
+
" :root {",
|
|
20912
|
+
...lines,
|
|
20913
|
+
" }",
|
|
20914
|
+
"}"
|
|
20915
|
+
].join("\n");
|
|
20916
|
+
}
|
|
20906
20917
|
function generateMotionBridgeVars(motionTokens) {
|
|
20907
20918
|
const lines = [];
|
|
20908
20919
|
for (const token of motionTokens) {
|
|
20909
|
-
|
|
20910
|
-
|
|
20911
|
-
|
|
20912
|
-
|
|
20913
|
-
|
|
20914
|
-
const key = token.name.replace("motion-easing-", "");
|
|
20915
|
-
lines.push(` --ease-${key}: var(--rafters-ease-${key});`);
|
|
20916
|
-
}
|
|
20920
|
+
const parts = motionNamespaceParts(token.name);
|
|
20921
|
+
if (!parts) continue;
|
|
20922
|
+
const themePrefix = TAILWIND_THEME_PREFIX[parts.namespace];
|
|
20923
|
+
if (!themePrefix) continue;
|
|
20924
|
+
lines.push(` --${themePrefix}-${parts.member}: var(--${token.name});`);
|
|
20917
20925
|
}
|
|
20918
20926
|
return lines.join("\n");
|
|
20919
20927
|
}
|
|
20920
20928
|
function generateMotionNamespaceUtilities(motionTokens) {
|
|
20921
|
-
const lines = ["/*
|
|
20929
|
+
const lines = ["/* Motion namespaces Tailwind has no theme key for */"];
|
|
20922
20930
|
let emitted = 0;
|
|
20923
20931
|
for (const token of motionTokens) {
|
|
20924
20932
|
const parts = motionNamespaceParts(token.name);
|
|
20925
20933
|
if (!parts) continue;
|
|
20934
|
+
if (TAILWIND_THEME_PREFIX[parts.namespace]) continue;
|
|
20926
20935
|
const property = MOTION_NAMESPACE_PROPERTY[parts.namespace];
|
|
20927
20936
|
emitted++;
|
|
20928
20937
|
lines.push(`@utility ${parts.namespace}-${parts.member} {`);
|
|
20929
20938
|
lines.push(` ${property}: var(--${token.name});`);
|
|
20930
|
-
if (REDUCED_MOTION_ZEROED.has(parts.namespace)) {
|
|
20931
|
-
lines.push(" @media (prefers-reduced-motion: reduce) {");
|
|
20932
|
-
lines.push(` ${property}: 0ms;`);
|
|
20933
|
-
lines.push(" }");
|
|
20934
|
-
}
|
|
20935
20939
|
lines.push("}");
|
|
20936
20940
|
}
|
|
20937
20941
|
return emitted === 0 ? "" : lines.join("\n");
|
|
@@ -21001,6 +21005,11 @@ function tokensToTailwind(tokens, options = {}, typographyOverrides = []) {
|
|
|
21001
21005
|
if (keyframes) {
|
|
21002
21006
|
sections.push(keyframes);
|
|
21003
21007
|
}
|
|
21008
|
+
const reducedMotionLaw = generateReducedMotionLaw(groups.motion);
|
|
21009
|
+
if (reducedMotionLaw) {
|
|
21010
|
+
sections.push("");
|
|
21011
|
+
sections.push(reducedMotionLaw);
|
|
21012
|
+
}
|
|
21004
21013
|
const typographyThemeInline = generateTypographyCompositeThemeInline(
|
|
21005
21014
|
groups["typography-composite"]
|
|
21006
21015
|
);
|
|
@@ -21028,11 +21037,6 @@ function tokensToTailwind(tokens, options = {}, typographyOverrides = []) {
|
|
|
21028
21037
|
sections.push("");
|
|
21029
21038
|
sections.push(motionUtilities);
|
|
21030
21039
|
}
|
|
21031
|
-
const cellUtilities = generateMotionCellUtilities(groups.motion);
|
|
21032
|
-
if (cellUtilities) {
|
|
21033
|
-
sections.push("");
|
|
21034
|
-
sections.push(cellUtilities);
|
|
21035
|
-
}
|
|
21036
21040
|
const overrideCSS = generateTypographyOverrideCSS(typographyOverrides);
|
|
21037
21041
|
if (overrideCSS) {
|
|
21038
21042
|
sections.push("");
|
|
@@ -22294,6 +22298,28 @@ var DEFAULT_KEYFRAME_DEFINITIONS = {
|
|
|
22294
22298
|
meaning: "Scale down while fading out",
|
|
22295
22299
|
contexts: ["modal-exit", "popover-close"]
|
|
22296
22300
|
},
|
|
22301
|
+
"grow-in": {
|
|
22302
|
+
// scaleY, not a uniform scale -- a bar growing from the baseline to its
|
|
22303
|
+
// full height is STRUCTURAL geometry (fixed, like the accordion
|
|
22304
|
+
// chevron's 180deg rotation), not a pop: no opacity, no other numeric.
|
|
22305
|
+
// The transform-origin anchoring the growth at the bottom (the
|
|
22306
|
+
// value-axis baseline) is the caller-set `transform-origin`
|
|
22307
|
+
// (bar-chart.behavior.ts's `transformOriginFor`), not this keyframe --
|
|
22308
|
+
// this declares only the scaleY 0 -> 1 extent.
|
|
22309
|
+
css: () => "from { transform: scaleY(0); } to { transform: scaleY(1); }",
|
|
22310
|
+
meaning: "Grow from zero to full height, anchored at the baseline (caller-set transform-origin)",
|
|
22311
|
+
contexts: ["bar-chart", "value-display"]
|
|
22312
|
+
},
|
|
22313
|
+
"grow-in-x": {
|
|
22314
|
+
// The horizontal-layout counterpart of grow-in: scaleX, not scaleY --
|
|
22315
|
+
// `layout: 'horizontal'` swaps computeBars' value axis from y to x
|
|
22316
|
+
// (bar-chart.behavior.ts), so the structural growth axis swaps with it.
|
|
22317
|
+
// Same rationale as grow-in: no opacity, no other numeric, transform-
|
|
22318
|
+
// origin is caller-set (transformOriginFor).
|
|
22319
|
+
css: () => "from { transform: scaleX(0); } to { transform: scaleX(1); }",
|
|
22320
|
+
meaning: "Grow from zero to full width, anchored at the baseline (caller-set transform-origin)",
|
|
22321
|
+
contexts: ["bar-chart", "value-display"]
|
|
22322
|
+
},
|
|
22297
22323
|
spin: {
|
|
22298
22324
|
css: () => "from { transform: rotate(0deg); } to { transform: rotate(360deg); }",
|
|
22299
22325
|
meaning: "Continuous rotation",
|
|
@@ -22739,6 +22765,38 @@ var DEFAULT_MOTION_CELL_ANIMATIONS = {
|
|
|
22739
22765
|
meaning: "The incoming tab panel as the selection moves: the calendar month-change moment on a panel instead of a grid, one tier quicker because a panel swap covers no distance.",
|
|
22740
22766
|
contexts: ["tabs", "panel", "crossfade"]
|
|
22741
22767
|
},
|
|
22768
|
+
"bar-chart-bar-enter": {
|
|
22769
|
+
keyframe: "grow-in",
|
|
22770
|
+
duration: { kind: "tier", tier: "normal" },
|
|
22771
|
+
curve: "enter",
|
|
22772
|
+
cell: { component: "bar-chart", part: "bar", transition: "enter" },
|
|
22773
|
+
meaning: "A bar arriving: grows from zero at the value-axis baseline (bar-chart.behavior.ts sets the transform-origin), on the arrival curve. Vertical layout, the default -- see bar-chart-bar-enter-x for the horizontal counterpart.",
|
|
22774
|
+
contexts: ["bar-chart", "chart", "value-display"]
|
|
22775
|
+
},
|
|
22776
|
+
"bar-chart-bar-enter-x": {
|
|
22777
|
+
keyframe: "grow-in-x",
|
|
22778
|
+
duration: { kind: "tier", tier: "normal" },
|
|
22779
|
+
curve: "enter",
|
|
22780
|
+
cell: { component: "bar-chart", part: "bar", transition: "enter-horizontal" },
|
|
22781
|
+
meaning: 'A bar arriving under layout: "horizontal": grows from zero at the value-axis baseline, now the left edge (bar-chart.behavior.ts sets the transform-origin), on the same arrival curve as the vertical bar-chart-bar-enter cell -- only the transform property (scaleX, not scaleY) changes with the swapped axis.',
|
|
22782
|
+
contexts: ["bar-chart", "chart", "value-display"]
|
|
22783
|
+
},
|
|
22784
|
+
"area-chart-area-enter": {
|
|
22785
|
+
keyframe: "fade-in",
|
|
22786
|
+
duration: { kind: "tier", tier: "moderate" },
|
|
22787
|
+
curve: "enter",
|
|
22788
|
+
cell: { component: "area-chart", part: "area", transition: "enter" },
|
|
22789
|
+
meaning: "A filled area arriving on mount: fade, not scale -- unlike bar-chart-bar-enter, a stacked area series has no single baseline edge to grow from (its baseline is the previous series own top curve, area-chart.behavior.ts computeAreas), so opacity is the one property every area shares whether overlaid or stacked.",
|
|
22790
|
+
contexts: ["area-chart", "chart", "value-display"]
|
|
22791
|
+
},
|
|
22792
|
+
"line-chart-line-enter": {
|
|
22793
|
+
keyframe: "fade-in",
|
|
22794
|
+
duration: { kind: "tier", tier: "moderate" },
|
|
22795
|
+
curve: "enter",
|
|
22796
|
+
cell: { component: "line-chart", part: "line", transition: "enter" },
|
|
22797
|
+
meaning: "A line series arriving on mount: fades in rather than snapping into place. The matrix assigns opacity over a stroke-dashoffset reveal for this cell -- a dashoffset keyframe needs a per-instance path-length value nothing here names, where a fade needs none.",
|
|
22798
|
+
contexts: ["line-chart", "chart", "value-display"]
|
|
22799
|
+
},
|
|
22742
22800
|
// ------------------------------------------------------------- load / appearance
|
|
22743
22801
|
"avatar-image-load": {
|
|
22744
22802
|
keyframe: "fade-in",
|
|
@@ -29639,6 +29697,21 @@ async function installWithPackageManager(packageManager, dependencies, options)
|
|
|
29639
29697
|
|
|
29640
29698
|
// src/utils/install-registry-deps.ts
|
|
29641
29699
|
var RAFTERS_SCOPE_PREFIX = "@rafters/";
|
|
29700
|
+
var PLACEHOLDER_DEPENDENCY_NAMES = /* @__PURE__ */ new Set(["none", "n/a"]);
|
|
29701
|
+
function isPlaceholderDependencyName(name) {
|
|
29702
|
+
const normalized = name.trim().toLowerCase();
|
|
29703
|
+
return normalized === "" || PLACEHOLDER_DEPENDENCY_NAMES.has(normalized);
|
|
29704
|
+
}
|
|
29705
|
+
var PlaceholderDependencyError = class extends Error {
|
|
29706
|
+
constructor(itemName, dependency) {
|
|
29707
|
+
super(
|
|
29708
|
+
`Registry item "${itemName}" declares dependency "${dependency}", which is not a real npm package name -- it looks like a placeholder left in the manifest. Refusing to install it.`
|
|
29709
|
+
);
|
|
29710
|
+
this.itemName = itemName;
|
|
29711
|
+
this.dependency = dependency;
|
|
29712
|
+
this.name = "PlaceholderDependencyError";
|
|
29713
|
+
}
|
|
29714
|
+
};
|
|
29642
29715
|
var REACT_RUNTIME_PACKAGES = /* @__PURE__ */ new Set(["react", "react-dom", "@types/react", "@types/react-dom"]);
|
|
29643
29716
|
function parseDependency(dep) {
|
|
29644
29717
|
const trimmed = dep.trim();
|
|
@@ -29685,6 +29758,16 @@ async function installRegistryDependencies(items, targetDir, options = {}) {
|
|
|
29685
29758
|
devInstalled: [],
|
|
29686
29759
|
failed: []
|
|
29687
29760
|
};
|
|
29761
|
+
for (const item of items) {
|
|
29762
|
+
for (const file2 of item.files) {
|
|
29763
|
+
for (const dep of file2.dependencies) {
|
|
29764
|
+
const { name } = parseDependency(dep);
|
|
29765
|
+
if (isPlaceholderDependencyName(name)) {
|
|
29766
|
+
throw new PlaceholderDependencyError(item.name, dep);
|
|
29767
|
+
}
|
|
29768
|
+
}
|
|
29769
|
+
}
|
|
29770
|
+
}
|
|
29688
29771
|
const allDeps = new Set(items.flatMap((item) => item.files.flatMap((file2) => file2.dependencies)));
|
|
29689
29772
|
if (allDeps.size === 0) {
|
|
29690
29773
|
return result;
|
|
@@ -30464,13 +30547,23 @@ async function add(componentArgs, options) {
|
|
|
30464
30547
|
target
|
|
30465
30548
|
});
|
|
30466
30549
|
} catch (err) {
|
|
30467
|
-
|
|
30468
|
-
|
|
30469
|
-
|
|
30470
|
-
|
|
30471
|
-
|
|
30472
|
-
|
|
30473
|
-
|
|
30550
|
+
if (err instanceof PlaceholderDependencyError) {
|
|
30551
|
+
log({
|
|
30552
|
+
event: "add:deps:install-failed",
|
|
30553
|
+
message: err.message,
|
|
30554
|
+
dependencies: [],
|
|
30555
|
+
suggestion: "This is a registry defect, not a local problem -- do not install it manually."
|
|
30556
|
+
});
|
|
30557
|
+
process.exitCode = 1;
|
|
30558
|
+
} else {
|
|
30559
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
30560
|
+
log({
|
|
30561
|
+
event: "add:deps:install-failed",
|
|
30562
|
+
message: `Failed to process dependencies: ${message}`,
|
|
30563
|
+
dependencies: [],
|
|
30564
|
+
suggestion: "Check package.json and try installing dependencies manually."
|
|
30565
|
+
});
|
|
30566
|
+
}
|
|
30474
30567
|
}
|
|
30475
30568
|
if (depsResult.installed.length > 0 || depsResult.skipped.length > 0) {
|
|
30476
30569
|
log({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rafters",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Design Intelligence CLI. Scaffold tokens, import existing shadcn/Tailwind v4 sources, add components, and serve an MCP server so AI agents read decisions instead of guessing.",
|
|
5
5
|
"homepage": "https://rafters.studio",
|
|
6
6
|
"license": "MIT",
|