diff --git a/.changeset/plain-base-class-derivations.md b/.changeset/plain-base-class-derivations.md
new file mode 100644
index 00000000..2932c7a0
--- /dev/null
+++ b/.changeset/plain-base-class-derivations.md
@@ -0,0 +1,4 @@
+---
+'svforge': patch
+---
+Simplify the single-use class expressions in Base Badge, Alert, and Card components while preserving reactive variants.
diff --git a/graphify-out/graph.json b/graphify-out/graph.json
index 22d53ab5..7fc82124 100644
--- a/graphify-out/graph.json
+++ b/graphify-out/graph.json
@@ -14684,7 +14684,7 @@
"label": "children()",
"file_type": "code",
"source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte",
- "source_location": "L37",
+ "source_location": "L36",
"_callable": true,
"_origin": "ast"
},
@@ -21932,6 +21932,22 @@
"source_location": "L19",
"_origin": "ast"
},
+ {
+ "id": "tests_base_derived_classes_test",
+ "label": "base-derived-classes.test.ts",
+ "file_type": "code",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L1",
+ "_origin": "ast"
+ },
+ {
+ "id": "tests_base_derived_classes_test_children",
+ "label": "children",
+ "file_type": "code",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L11",
+ "_origin": "ast"
+ },
{
"id": "tests_base_vitest_test",
"label": "base-vitest.test.ts",
@@ -26363,6 +26379,15 @@
"source_location": "L1",
"_origin": "ast"
},
+ {
+ "id": "changeset_plain_base_class_derivations",
+ "label": "plain-base-class-derivations.md",
+ "file_type": "document",
+ "node_kind": "page",
+ "source_file": ".changeset/plain-base-class-derivations.md",
+ "source_location": "L1",
+ "_origin": "ast"
+ },
{
"id": "changeset_quiet_lib_imports",
"label": "quiet-lib-imports.md",
@@ -58825,7 +58850,7 @@
"relation": "contains",
"confidence": "EXTRACTED",
"source_file": "packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte",
- "source_location": "L37",
+ "source_location": "L36",
"weight": 1,
"_origin": "ast"
},
@@ -74144,6 +74169,82 @@
"weight": 1,
"_origin": "ast"
},
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "ref_vitest",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L2",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "ref_testing_library_svelte",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L3",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "ref_svelte",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L4",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "packages_svforge_templates_base_src_lib_components_svforge_ui_alert",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L5",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "packages_svforge_templates_base_src_lib_components_svforge_primitives_badge",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L6",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "packages_svforge_templates_base_src_lib_components_svforge_ui_card",
+ "relation": "imports_from",
+ "context": "import",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L7",
+ "weight": 1,
+ "_origin": "ast"
+ },
+ {
+ "source": "tests_base_derived_classes_test",
+ "target": "tests_base_derived_classes_test_children",
+ "relation": "contains",
+ "confidence": "EXTRACTED",
+ "source_file": "tests/base-derived-classes.test.ts",
+ "source_location": "L11",
+ "weight": 1,
+ "_origin": "ast"
+ },
{
"source": "tests_base_vitest_test",
"target": "ref_vitest",
diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json
index adf3fd2f..85cdd71a 100644
--- a/graphify-out/manifest.json
+++ b/graphify-out/manifest.json
@@ -488,7 +488,7 @@
"semantic_hash": ""
},
"packages/svforge/src/templates.ts": {
- "ast_hash": "45944ccabfdddddea22cb9f895bd8399",
+ "ast_hash": "3136b546212ced01f45ec40895b95434",
"semantic_hash": ""
},
"packages/svforge/src/upgrade.ts": {
@@ -572,7 +572,7 @@
"semantic_hash": ""
},
"packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte": {
- "ast_hash": "1770661d69922df5e4ecffbc64116c7b",
+ "ast_hash": "2e4c9fc1f776ce4179de9f01c2fcb8c1",
"semantic_hash": ""
},
"packages/svforge/templates/base/src/lib/components/svforge/primitives/Button.svelte": {
@@ -608,11 +608,11 @@
"semantic_hash": ""
},
"packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte": {
- "ast_hash": "1ed6de77dc0081213f774263a143f432",
+ "ast_hash": "f76f5ea141808a7bbdbdf68026e0cd69",
"semantic_hash": ""
},
"packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte": {
- "ast_hash": "4254dfefc2fbc9f17dd1a41f15642544",
+ "ast_hash": "01a989ccc1b348ca5f8bc4271fce47c4",
"semantic_hash": ""
},
"packages/svforge/templates/base/src/lib/components/svforge/ui/Logo.svelte": {
@@ -2102,5 +2102,13 @@
".changeset/quiet-lib-imports.md": {
"ast_hash": "c5f0c8287e0323fa95e78b17fae6eff4",
"semantic_hash": ""
+ },
+ "tests/base-derived-classes.test.ts": {
+ "ast_hash": "3d63aa4e9d6cf63788d92d839776270b",
+ "semantic_hash": ""
+ },
+ ".changeset/plain-base-class-derivations.md": {
+ "ast_hash": "7778c6938a8841c5a60545fb9b168b42",
+ "semantic_hash": ""
}
}
diff --git a/packages/svforge/src/templates.ts b/packages/svforge/src/templates.ts
index f1921324..58de035b 100644
--- a/packages/svforge/src/templates.ts
+++ b/packages/svforge/src/templates.ts
@@ -10,7 +10,7 @@ export const baseFiles = {
"/lib/components/svforge/layout/Footer.svelte": "\n\n\n",
"/lib/components/svforge/layout/Navbar.svelte": "\n\n\n\t\n\n\t\n\t{#if mobileOpen}\n\t\t\n\t{/if}\n \n",
"/lib/components/svforge/layout/index.ts": "export { default as AuthLayout } from './AuthLayout.svelte';\nexport { default as Footer } from './Footer.svelte';\nexport { default as Navbar } from './Navbar.svelte';\n",
- "/lib/components/svforge/primitives/Badge.svelte": "\n\n\n\t{@render children()}\n \n",
+ "/lib/components/svforge/primitives/Badge.svelte": "\n\n\n\t{@render children()}\n \n",
"/lib/components/svforge/primitives/Button.svelte": "\n\n{#snippet spinner()}\n\t \n\t{loadingLabel} \n{/snippet}\n\n{#if isAnchor}\n\t\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t \n{:else}\n\t\n\t\n\t\t{#if loading}\n\t\t\t{@render spinner()}\n\t\t{/if}\n\t\t{@render children()}\n\t \n{/if}\n",
"/lib/components/svforge/primitives/Checkbox.svelte": "\n\n\n\t \n\t{#if label}\n\t\t{label} \n\t{/if}\n \n",
"/lib/components/svforge/primitives/Input.svelte": "\n\n
\n\t{#if label}\n\t\t
\n\t\t\t{label}\n\t\t \n\t{/if}\n\t
\n\t\t
\n\t\t{#if trailing}\n\t\t\t
\n\t\t\t\t{@render trailing()}\n\t\t\t
\n\t\t{/if}\n\t
\n\t{#if hasError}\n\t\t
{error}
\n\t{/if}\n
\n",
@@ -19,8 +19,8 @@ export const baseFiles = {
"/lib/components/svforge/primitives/Toggle.svelte": "\n\n\n\t\n\t\t \n\t \n\t{#if label}\n\t\t{label} \n\t{/if}\n\t \n \n",
"/lib/components/svforge/primitives/index.ts": "export { default as Badge } from './Badge.svelte';\nexport { default as Button } from './Button.svelte';\nexport { default as Checkbox } from './Checkbox.svelte';\nexport { default as Input } from './Input.svelte';\nexport { default as Select } from './Select.svelte';\nexport { default as Textarea } from './Textarea.svelte';\nexport { default as Toggle } from './Toggle.svelte';\n",
"/lib/components/svforge/primitives/types.ts": "/**\n * Plain-TypeScript prop contracts for the primitives (#321).\n *\n * Living in a .ts module — instead of inline in the .svelte files — keeps the\n * discriminated unions and generics importable by type tests (`vitest\n * --typecheck`) and by consumers, without going through Svelte tooling.\n */\nimport type { Snippet } from 'svelte';\nimport type {\n\tHTMLAnchorAttributes,\n\tHTMLButtonAttributes,\n\tHTMLTableAttributes\n} from 'svelte/elements';\n\n// ── Button ───────────────────────────────────────────────────────────────\n\nexport type ButtonVariant = 'filled' | 'outlined' | 'tonal' | 'ghost';\nexport type ButtonColor =\n\t| 'primary'\n\t| 'secondary'\n\t| 'tertiary'\n\t| 'success'\n\t| 'warning'\n\t| 'error'\n\t| 'surface';\nexport type ButtonSize = 'sm' | 'md' | 'lg';\n\n/** Visual + ergonomic props shared by both branches of the union. */\nexport interface ButtonSharedProps {\n\tvariant?: ButtonVariant;\n\tcolor?: ButtonColor;\n\tsize?: ButtonSize;\n\t/**\n\t * Visual disabled state. The button branch renders a native `disabled`\n\t * attribute; the anchor branch degrades to `aria-disabled` + reduced\n\t * styling — anchors have no native disabled semantics (#321).\n\t */\n\tdisabled?: boolean;\n\tloading?: boolean;\n\t/**\n\t * Text announced to screen readers while `loading` is active — the visual\n\t * spinner is aria-hidden. Defaults to the localized `common_loading` copy.\n\t */\n\tloadingLabel?: string;\n\tclass?: string;\n\tchildren: Snippet;\n}\n\n/**\n * Button branch — rendered when `href` is absent OR an empty string (the\n * runtime discriminant, mirrored exactly). Anchor-only attributes (`target`,\n * `rel`) are rejected here: TypeScript enforces that each branch only carries\n * its own element's attributes (#321). `href` admits `''` so a dynamically\n * computed empty href keeps full button semantics (`type`, `name`, `form`…)\n * instead of being mis-typed into the anchor branch.\n */\nexport type ButtonProps = ButtonSharedProps &\n\tOmit & {\n\t\thref?: '' | undefined;\n\t\ttype?: 'button' | 'submit' | 'reset';\n\t};\n\n/**\n * Anchor branch — rendered when `href` is a non-empty string. Button-only\n * semantics (`type`, `name`, `form`) are rejected: a link styled as a button\n * is still a link, and a disabled/loading one keeps anchor semantics via\n * `aria-disabled` instead of falling back to a `` (#321).\n *\n * Non-emptiness is enforced at RUNTIME (an empty href falls back to the\n * button branch): TypeScript cannot express `string & not ''` cleanly, so\n * the type stays the honest plain `string`.\n */\nexport type AnchorProps = ButtonSharedProps &\n\tOmit & {\n\t\thref: string;\n\t\ttype?: undefined;\n\t};\n\n/**\n * Discriminated union on `href`, mirroring Button.svelte's runtime rule\n * exactly: a non-empty string selects the anchor branch; absence or `''`\n * selects the button branch (#321).\n */\nexport type ButtonOrAnchorProps = ButtonProps | AnchorProps;\n\n// ── Table ────────────────────────────────────────────────────────────────\n\nexport interface TableColumn {\n\tkey: string;\n\tlabel: string;\n\tclass?: string;\n}\n\n/**\n * Stable row identity (#321): a field name on the row object, or a function\n * deriving the key. Array indices are never a valid key source — index keys\n * corrupt keyed reconciliation on insert/remove/reorder.\n */\nexport type TableRowKey = (keyof Row) & string | ((row: Row) => string | number);\n\n/** Table attributes and content — shared by every `Row` shape. */\ninterface TableBaseProps extends Omit {\n\tcolumns: TableColumn[];\n\trows: Row[];\n\t/** Accessible table caption — plain text or a snippet for rich content. */\n\tcaption?: string | Snippet;\n\tclass?: string;\n\t/** Optional per-cell renderer for rich cells (avatar, badge, actions…). */\n\tchildren?: Snippet<[{ row: Row; col: TableColumn }]>;\n\t/** Rendered in a full-width cell when `rows` is empty. */\n\tempty?: Snippet;\n}\n\n/**\n * Structured table contract (#321): attributes land on the `` element\n * (not an outer div), rows are generic, and the per-cell renderer receives the\n * typed row. Object rows REQUIRE a `rowKey`; primitive rows key on their own\n * value and must not pass one.\n */\nexport type TableProps = TableBaseProps &\n\t(Row extends object ? { rowKey: TableRowKey } : { rowKey?: undefined });\n",
- "/lib/components/svforge/ui/Alert.svelte": "\n\n\n\t{@render children()}\n
\n",
- "/lib/components/svforge/ui/Card.svelte": "\n\n\n\t{#if header}\n\t\t
\n\t\t\t{@render header()}\n\t\t
\n\t{/if}\n\n\t{@render children()}\n\n\t{#if footer}\n\t\t
\n\t\t\t{@render footer()}\n\t\t
\n\t{/if}\n
\n",
+ "/lib/components/svforge/ui/Alert.svelte": "\n\n\n\t{@render children()}\n
\n",
+ "/lib/components/svforge/ui/Card.svelte": "\n\n\n\t{#if header}\n\t\t
\n\t\t\t{@render header()}\n\t\t
\n\t{/if}\n\n\t{@render children()}\n\n\t{#if footer}\n\t\t
\n\t\t\t{@render footer()}\n\t\t
\n\t{/if}\n
\n",
"/lib/components/svforge/ui/Logo.svelte": "\n\n\n\t\n\t\t \n\t \n\t{#if brandName}{brandName} {/if}\n \n",
"/lib/components/svforge/ui/Seo.svelte": "\n\n\n\t{title} \n\t \n\t \n\t \n\t \n\t \n\t \n\t{#if resolvedImage}\n\t\t \n\t{/if}\n\t \n\t \n\t \n\t{#if resolvedImage}\n\t\t \n\t{/if}\n \n",
"/lib/components/svforge/ui/Sitemap.ts": "interface SitemapEntry {\n\tpath: string;\n\tlastmod?: string;\n\tchangefreq?: string;\n\tpriority?: number;\n}\n\nconst CHANGE_FREQUENCIES = new Set(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']);\nconst MAX_TEXT_LENGTH = 2_048;\n\nfunction escapeXml(value: string): string {\n\treturn value.replace(/[&<>\"']/g, (character) => ({\n\t\t'&': '&',\n\t\t'<': '<',\n\t\t'>': '>',\n\t\t'\"': '"',\n\t\t\"'\": '''\n\t})[character]!);\n}\n\nfunction boundedText(value: string | undefined): string | undefined {\n\treturn value && value.length <= MAX_TEXT_LENGTH ? value : undefined;\n}\n\nexport function generateSitemap(baseUrl: string, routes: SitemapEntry[]): string {\n\tconst safeBaseUrl = boundedText(baseUrl) ?? '';\n\tconst entries = routes\n\t\t.map((route) => {\n\t\t\tconst path = boundedText(route.path) ?? '/';\n\t\t\tconst loc = escapeXml(`${safeBaseUrl}${path}`);\n\t\t\tlet xml = ` \\n ${loc} `;\n\t\t\tconst lastmod = boundedText(route.lastmod);\n\t\t\tif (lastmod) xml += `\\n ${escapeXml(lastmod)} `;\n\t\t\tif (route.changefreq && CHANGE_FREQUENCIES.has(route.changefreq)) {\n\t\t\t\txml += `\\n ${escapeXml(route.changefreq)} `;\n\t\t\t}\n\t\t\tif (typeof route.priority === 'number' && route.priority >= 0 && route.priority <= 1) {\n\t\t\t\txml += `\\n ${route.priority} `;\n\t\t\t}\n\t\t\txml += '\\n ';\n\t\t\treturn xml;\n\t\t})\n\t\t.join('\\n');\n\n\treturn `\n\n${entries}\n `;\n}\n",
diff --git a/packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte b/packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte
index 03d82894..dd1612aa 100644
--- a/packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte
+++ b/packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte
@@ -56,11 +56,8 @@
// --badge-size. The former size prop emitted invented per-size classes
// (dead in v5, rejected by svforge check); a smaller/larger badge is a
// theme change (--badge-size), not a local override.
- let presetClass = $derived(presets[variant][color]);
-
- let classes = $derived(cn('badge', presetClass, className));
-
+
{@render children()}
diff --git a/packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte b/packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte
index 09cedf75..94c13456 100644
--- a/packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte
+++ b/packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte
@@ -22,10 +22,8 @@
warning: 'preset-tonal-warning',
error: 'preset-tonal-error'
};
-
- let classes = $derived(cn('card p-2', presets[variant], className));
-
+
{@render children()}
diff --git a/packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte b/packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte
index 36020777..ccec54ed 100644
--- a/packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte
+++ b/packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte
@@ -24,10 +24,9 @@
// #317: the Skeleton `card` utility owns the container radius
// (border-radius: var(--radius-container)) — never reapply rounded-*.
// `p-4` stays: `card` applies no padding (Tailwind owns local spacing).
- let classes = $derived(cn(variantClasses[variant], 'p-4', className));
-
+
{#if header}
{@render header()}
diff --git a/tests/base-derived-classes.test.ts b/tests/base-derived-classes.test.ts
new file mode 100644
index 00000000..82108946
--- /dev/null
+++ b/tests/base-derived-classes.test.ts
@@ -0,0 +1,62 @@
+// @vitest-environment jsdom
+import { afterEach, describe, expect, it } from 'vitest';
+import { cleanup, render } from '@testing-library/svelte';
+import { createRawSnippet } from 'svelte';
+import Alert from '../packages/svforge/templates/base/src/lib/components/svforge/ui/Alert.svelte';
+import Badge from '../packages/svforge/templates/base/src/lib/components/svforge/primitives/Badge.svelte';
+import Card from '../packages/svforge/templates/base/src/lib/components/svforge/ui/Card.svelte';
+
+afterEach(cleanup);
+
+const children = createRawSnippet(() => ({ render: () => 'Content ' }));
+
+describe('Base single-use class expressions stay reactive', () => {
+ it('updates Badge preset classes when variant and color change', async () => {
+ const { container, rerender } = render(Badge, {
+ props: { children, class: 'custom-badge' }
+ });
+ const badge = container.querySelector('span')!;
+
+ expect(badge.classList).toContain('preset-filled-primary-400-600');
+ expect(badge.classList).toContain('custom-badge');
+
+ await rerender({ children, class: 'custom-badge', variant: 'outlined', color: 'error' });
+
+ expect(badge.classList).toContain('preset-outlined-error-900-100');
+ expect(badge.classList).not.toContain('preset-filled-primary-400-600');
+ expect(badge.classList).toContain('custom-badge');
+ });
+
+ it('updates Alert preset classes while preserving consumer classes', async () => {
+ const { container, rerender } = render(Alert, {
+ props: { children, class: 'custom-alert' }
+ });
+ const alert = container.firstElementChild!;
+
+ expect(alert.classList).toContain('preset-tonal-primary');
+ expect(alert.classList).toContain('custom-alert');
+
+ await rerender({ children, class: 'custom-alert', variant: 'success' });
+
+ expect(alert.classList).toContain('preset-tonal-success');
+ expect(alert.classList).not.toContain('preset-tonal-primary');
+ expect(alert.classList).toContain('custom-alert');
+ });
+
+ it('updates Card variant classes while retaining the Skeleton surface', async () => {
+ const { container, rerender } = render(Card, {
+ props: { children, class: 'custom-card' }
+ });
+ const card = container.firstElementChild!;
+
+ expect(card.classList).toContain('preset-filled-surface-50-950');
+ expect(card.classList).not.toContain('shadow-lg');
+ expect(card.classList).toContain('custom-card');
+
+ await rerender({ children, class: 'custom-card', variant: 'elevated' });
+
+ expect(card.classList).toContain('preset-filled-surface-50-950');
+ expect(card.classList).toContain('shadow-lg');
+ expect(card.classList).toContain('custom-card');
+ });
+});