@syncedco/flow 0.3.2 → 0.4.1

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.
@@ -1025,7 +1025,7 @@ function getTokenSummary() {
1025
1025
  themePresets: presetNames.map(kebabThemeName),
1026
1026
  starterClasses: {
1027
1027
  layout: ['sf-container', 'sf-container--narrow', 'sf-container--wide', 'sf-container--full', 'sf-rail-shell', 'sf-rail-shell__rail', 'sf-rail-shell__body', 'sf-rail-shell__mark', 'sf-rail-shell__label', 'sf-section', 'sf-section--compact', 'sf-section--spacious', 'sf-stack', 'sf-flow', 'sf-cluster', 'sf-repel', 'sf-toolbar', 'sf-app-shell', 'sf-app-sidebar', 'sf-app-sidebar__brand', 'sf-app-header', 'sf-app-backdrop', 'sf-app-main', 'sf-auto-grid', 'sf-auto-grid--xs', 'sf-auto-grid--sm', 'sf-auto-grid--lg', 'sf-switcher', 'sf-sidebar', 'sf-split', 'sf-split--reverse', 'sf-chapter', 'sf-chapter__copy', 'sf-frame', 'sf-cover', 'sf-panel-grid', 'sf-metric-grid', 'sf-pipeline', 'sf-scroll-viewport', 'sf-scroll-panel', 'sf-scroll-snap-y', 'sf-sticky-top', 'sf-media-object', 'sf-aside-rail'],
1028
- components: ['sf-button', 'sf-button-group', 'sf-button--icon', 'sf-icon-button', 'sf-icon', 'sf-icon--xs', 'sf-icon--sm', 'sf-icon--md', 'sf-icon--lg', 'sf-icon--xl', 'sf-card', 'sf-card--flat', 'sf-card--raised', 'sf-card--compact', 'sf-card--glass', 'sf-card--technical', 'sf-card--accent', 'sf-card--center', 'sf-surface', 'sf-code-window', 'sf-code-window--glow', 'sf-code-window__bar', 'sf-window-dots', 'sf-code-lines', 'sf-code-lines--compact', 'sf-code-block', 'sf-token-strip', 'sf-marquee', 'sf-marquee__track', 'sf-marquee__item', 'sf-command-list', 'sf-command-list__prompt', 'sf-platform-card', 'sf-platform-card__logo', 'sf-hero', 'sf-logo-cloud', 'sf-feature', 'sf-stats', 'sf-stat', 'sf-testimonial', 'sf-pricing-grid', 'sf-price-card', 'sf-price', 'sf-faq', 'sf-cta', 'sf-footer', 'sf-badge', 'sf-avatar', 'sf-chart', 'sf-chart--wide', 'sf-chart__plot', 'sf-chart__svg', 'sf-chart__grid', 'sf-chart__axis', 'sf-chart__area', 'sf-chart__line', 'sf-chart__point', 'sf-chart__legend', 'sf-chart__legend-item', 'sf-chart__swatch', 'sf-meter-list', 'sf-meter-row', 'sf-meter', 'sf-progress', 'sf-skeleton', 'sf-filter-bar', 'sf-search', 'sf-data-table', 'sf-table-sort', 'sf-status', 'sf-data-list', 'sf-empty-state', 'sf-settings-section', 'sf-detail-panel', 'sf-nav', 'sf-nav--mobile', 'sf-nav__list', 'sf-nav__link', 'sf-menu', 'sf-breadcrumb', 'sf-pagination', 'sf-dialog', 'sf-popover', 'sf-tooltip', 'sf-tooltip-trigger', 'sf-menu-popover', 'sf-toast', 'sf-toast-stack', 'sf-banner', 'sf-drawer', 'sf-drawer--stack', 'sf-disclosure', 'sf-accordion', 'sf-tabs', 'sf-tab-list', 'sf-tab', 'sf-tab__count', 'sf-tab-panel', 'sf-form', 'sf-fieldset', 'sf-field', 'sf-label', 'sf-help', 'sf-error', 'sf-input', 'sf-select', 'sf-textarea', 'sf-check', 'sf-alert', 'sf-section-header', 'sf-kicker'],
1028
+ components: ['sf-button', 'sf-button-group', 'sf-button--icon', 'sf-icon-button', 'sf-icon', 'sf-icon--xs', 'sf-icon--sm', 'sf-icon--md', 'sf-icon--lg', 'sf-icon--xl', 'sf-spinner', 'sf-card', 'sf-card--flat', 'sf-card--raised', 'sf-card--compact', 'sf-card--glass', 'sf-card--technical', 'sf-card--accent', 'sf-card--center', 'sf-surface', 'sf-code-window', 'sf-code-window--glow', 'sf-code-window__bar', 'sf-window-dots', 'sf-code-lines', 'sf-code-lines--compact', 'sf-code-block', 'sf-token-strip', 'sf-marquee', 'sf-marquee__track', 'sf-marquee__item', 'sf-command-list', 'sf-command-list__prompt', 'sf-platform-card', 'sf-platform-card__logo', 'sf-hero', 'sf-logo-cloud', 'sf-feature', 'sf-stats', 'sf-stat', 'sf-testimonial', 'sf-pricing-grid', 'sf-price-card', 'sf-price', 'sf-faq', 'sf-cta', 'sf-footer', 'sf-badge', 'sf-avatar', 'sf-chart', 'sf-chart--wide', 'sf-chart__plot', 'sf-chart__svg', 'sf-chart__grid', 'sf-chart__axis', 'sf-chart__area', 'sf-chart__line', 'sf-chart__point', 'sf-chart__legend', 'sf-chart__legend-item', 'sf-chart__swatch', 'sf-meter-list', 'sf-meter-row', 'sf-meter', 'sf-progress', 'sf-skeleton', 'sf-filter-bar', 'sf-search', 'sf-input-group', 'sf-input-group__leading', 'sf-input-group__trailing', 'sf-input-group__action', 'sf-file-input', 'sf-range', 'sf-switch', 'sf-switch__control', 'sf-switch__label', 'sf-segmented-control', 'sf-segmented-control__option', 'sf-segmented-control__label', 'sf-combobox', 'sf-combobox__listbox', 'sf-combobox__option', 'sf-combobox__empty', 'sf-bulk-actions', 'sf-bulk-actions__count', 'sf-bulk-actions__controls', 'sf-stepper', 'sf-stepper__step', 'sf-stepper__marker', 'sf-stepper__label', 'sf-wizard-actions', 'sf-data-table', 'sf-table-sort', 'sf-status', 'sf-data-list', 'sf-empty-state', 'sf-settings-section', 'sf-detail-panel', 'sf-nav', 'sf-nav--mobile', 'sf-nav__list', 'sf-nav__link', 'sf-menu', 'sf-breadcrumb', 'sf-pagination', 'sf-pagination__summary', 'sf-pagination__controls', 'sf-pagination__pages', 'sf-pagination__jump', 'sf-dialog', 'sf-popover', 'sf-tooltip', 'sf-tooltip-trigger', 'sf-menu-popover', 'sf-toast', 'sf-toast-stack', 'sf-banner', 'sf-drawer', 'sf-drawer--stack', 'sf-disclosure', 'sf-accordion', 'sf-tabs', 'sf-tab-list', 'sf-tab', 'sf-tab__count', 'sf-tab-panel', 'sf-form', 'sf-fieldset', 'sf-field', 'sf-label', 'sf-help', 'sf-error', 'sf-input', 'sf-select', 'sf-textarea', 'sf-check', 'sf-alert', 'sf-section-header', 'sf-kicker'],
1029
1029
  type: ['sf-text-caption', 'sf-text-body', 'sf-text-lead', 'sf-text-h4', 'sf-text-h3', 'sf-text-h2', 'sf-text-h1', 'sf-text-display'],
1030
1030
  utilities: ['sr-only', 'not-sr-only', 'sf-visually-hidden', 'sf-not-visually-hidden', 'sf-skip-link', 'sf-focus-ring', 'sf-touch-target', 'sf-list-reset', 'sf-list-disc', 'sf-list-decimal', 'sf-link', 'sf-link-subtle', 'sf-link-plain', 'sf-prose', 'sf-prose--blog', 'sf-prose--legal', 'sf-meta', 'sf-figure', 'sf-caption', 'sf-table-wrap', 'sf-push-block-end', 'sf-push-block-start', 'sf-push-inline-end', 'sf-push-inline-start', 'sf-full-bleed', 'sf-text-muted', 'sf-bg-surface', 'sf-border', 'sf-rounded-panel', 'sf-shadow-md', 'sf-animate-fade', 'sf-animate-rise', 'sf-animate-scale', 'sf-animate-slide', 'sf-animate-stagger'],
1031
1031
  },
@@ -1082,7 +1082,7 @@ function getPublicPatterns() {
1082
1082
  <a class="sf-nav__link" href="#contact">Contact</a>
1083
1083
  </nav>
1084
1084
  </header>
1085
- <main class="sf-scroll-viewport sf-scroll-snap-y" id="main">
1085
+ <main class="sf-scroll-viewport sf-scroll-snap-y" id="main" tabindex="0">
1086
1086
  <section class="sf-scroll-panel" id="intro"><div class="sf-container sf-stack"><h1 class="sf-text-display">Intro panel</h1></div></section>
1087
1087
  <section class="sf-scroll-panel" id="work"><div class="sf-container sf-stack"><h2 class="sf-text-h1">Work panel</h2></div></section>
1088
1088
  <section class="sf-scroll-panel" id="contact"><div class="sf-container sf-stack"><h2 class="sf-text-h1">Contact panel</h2></div></section>
@@ -1100,24 +1100,65 @@ function getPublicPatterns() {
1100
1100
  a11y: ['Update aria-current on the active nav link.', 'Do not rely on colour alone for current state.', 'Respect prefers-reduced-motion for scripted scrolling.'],
1101
1101
  gotchas: ['Keep the observer script in the consuming app or example, not the Synced Flow package.', 'Anchor links still work without the optional script.'],
1102
1102
  markup: {
1103
- html: `<nav class="sf-container sf-nav sf-sticky-top" aria-label="Section navigation">
1103
+ html: `<nav class="sf-container sf-nav sf-sticky-top" aria-label="Section navigation" data-sf-scroll-spy-nav>
1104
1104
  <a class="sf-nav__link" href="#one" aria-current="location">One</a>
1105
1105
  <a class="sf-nav__link" href="#two">Two</a>
1106
1106
  </nav>
1107
- <main class="sf-scroll-viewport sf-scroll-snap-y" data-sf-scroll-spy>
1107
+ <main class="sf-scroll-viewport sf-scroll-snap-y" data-sf-scroll-spy tabindex="0">
1108
1108
  <section class="sf-scroll-panel" id="one"><div class="sf-container"><h1 class="sf-text-display">One</h1></div></section>
1109
1109
  <section class="sf-scroll-panel" id="two"><div class="sf-container"><h2 class="sf-text-h1">Two</h2></div></section>
1110
1110
  </main>
1111
1111
  <script>
1112
- const links = [...document.querySelectorAll('[data-sf-scroll-spy] ~ * a[href^="#"], nav a[href^="#"]')]
1113
- const panels = [...document.querySelectorAll('.sf-scroll-panel[id]')]
1112
+ const viewport = document.querySelector('[data-sf-scroll-spy]')
1113
+ const links = [...document.querySelectorAll('[data-sf-scroll-spy-nav] a[href^="#"]')]
1114
+ const panels = [...viewport.querySelectorAll('.sf-scroll-panel[id]')]
1114
1115
  const observer = new IntersectionObserver((entries) => {
1115
1116
  const active = entries.find((entry) => entry.isIntersecting)
1116
1117
  if (!active) return
1117
- for (const link of links) link.toggleAttribute('aria-current', link.hash === '#' + active.target.id)
1118
+ for (const link of links) {
1119
+ if (link.hash === '#' + active.target.id) link.setAttribute('aria-current', 'location')
1120
+ else link.removeAttribute('aria-current')
1121
+ }
1118
1122
  }, { threshold: 0.6 })
1119
1123
  for (const panel of panels) observer.observe(panel)
1120
1124
  </script>`,
1125
+ next: `'use client'
1126
+
1127
+ import { useEffect, useRef } from 'react'
1128
+
1129
+ export default function ScrollViewportWithSpy() {
1130
+ const navRef = useRef<HTMLElement>(null)
1131
+ const viewportRef = useRef<HTMLElement>(null)
1132
+
1133
+ useEffect(() => {
1134
+ const links = Array.from(navRef.current?.querySelectorAll<HTMLAnchorElement>('a[href^="#"]') ?? [])
1135
+ const panels = Array.from(viewportRef.current?.querySelectorAll<HTMLElement>('.sf-scroll-panel[id]') ?? [])
1136
+ const observer = new IntersectionObserver((entries) => {
1137
+ const active = entries.find((entry) => entry.isIntersecting)
1138
+ if (!active) return
1139
+ for (const link of links) {
1140
+ if (link.hash === '#' + active.target.id) link.setAttribute('aria-current', 'location')
1141
+ else link.removeAttribute('aria-current')
1142
+ }
1143
+ }, { threshold: 0.6 })
1144
+
1145
+ for (const panel of panels) observer.observe(panel)
1146
+ return () => observer.disconnect()
1147
+ }, [])
1148
+
1149
+ return (
1150
+ <>
1151
+ <nav ref={navRef} className="sf-container sf-nav sf-sticky-top" aria-label="Section navigation">
1152
+ <a className="sf-nav__link" href="#one" aria-current="location">One</a>
1153
+ <a className="sf-nav__link" href="#two">Two</a>
1154
+ </nav>
1155
+ <main ref={viewportRef} className="sf-scroll-viewport sf-scroll-snap-y" data-sf-scroll-spy tabIndex={0}>
1156
+ <section className="sf-scroll-panel" id="one"><div className="sf-container"><h1 className="sf-text-display">One</h1></div></section>
1157
+ <section className="sf-scroll-panel" id="two"><div className="sf-container"><h2 className="sf-text-h1">Two</h2></div></section>
1158
+ </main>
1159
+ </>
1160
+ )
1161
+ }`,
1121
1162
  },
1122
1163
  keywords: ['scroll', 'spy', 'intersectionobserver', 'portfolio', 'active', 'nav', 'sections'],
1123
1164
  },
@@ -1142,7 +1183,7 @@ function getPublicPatterns() {
1142
1183
  import { useRef } from 'react'
1143
1184
 
1144
1185
  export default function DialogExample() {
1145
- const dialogRef = useRef(null)
1186
+ const dialogRef = useRef<HTMLDialogElement>(null)
1146
1187
 
1147
1188
  return (
1148
1189
  <>
@@ -1228,7 +1269,7 @@ export default function DialogExample() {
1228
1269
  name: 'Scroll snap page sections',
1229
1270
  whenToUse: 'Portfolio panels, product storytelling, presentation-style pages, and full-page feature sections.',
1230
1271
  classes: ['sf-scroll-viewport', 'sf-scroll-snap-y', 'sf-scroll-panel', 'sf-sticky-top', 'sf-section', 'sf-container'],
1231
- markup: '<main class="sf-scroll-viewport" data-snap="mandatory"><section class="sf-scroll-panel">...</section></main>',
1272
+ markup: '<main class="sf-scroll-viewport" data-snap="mandatory" tabindex="0"><section class="sf-scroll-panel">...</section></main>',
1232
1273
  keywords: ['scroll', 'snap', 'panel', 'portfolio', 'presentation', 'full-page', 'sections'],
1233
1274
  },
1234
1275
  {
@@ -1285,12 +1326,12 @@ export default function DialogExample() {
1285
1326
  whenToUse: 'Filtered app views, inboxes, queues, and reports where each tab needs a compact count.',
1286
1327
  classes: ['sf-tabs', 'sf-tab-list', 'sf-tab', 'sf-tab__count', 'sf-tab-panel'],
1287
1328
  requiresJs: false,
1288
- requiresJsNotes: 'CSS styles state. The app owns aria-selected, routing, or tab panel switching for dynamic interfaces.',
1289
- a11y: ['Use role="tablist" only when implementing full keyboard tab behavior.', 'Use aria-selected on active tabs.', 'Keep counts textual and not colour-only.'],
1329
+ requiresJsNotes: 'CSS styles state. The app owns routing or tab panel switching for dynamic interfaces.',
1330
+ a11y: ['Use role="tablist" and aria-selected only when implementing full keyboard tab behavior.', 'Use aria-current="page" for the active route-backed link.', 'Keep counts textual and not colour-only.'],
1290
1331
  gotchas: ['Use data-variant="pill" for pill tabs.', 'Use links for route-backed tabs and buttons for in-page tab switching.'],
1291
1332
  markup: {
1292
1333
  html: `<nav class="sf-tab-list" aria-label="Views">
1293
- <a class="sf-tab" data-variant="pill" href="?view=open" aria-selected="true">Open <span class="sf-tab__count">12</span></a>
1334
+ <a class="sf-tab" data-variant="pill" href="?view=open" aria-current="page">Open <span class="sf-tab__count">12</span></a>
1294
1335
  <a class="sf-tab" data-variant="pill" href="?view=closed">Closed <span class="sf-tab__count">8</span></a>
1295
1336
  </nav>`,
1296
1337
  },
@@ -1306,9 +1347,9 @@ export default function DialogExample() {
1306
1347
  a11y: ['Use aria-sort on sorted columns.', 'Keep the row destination as a real link.', 'Add data-label to cells when using card fallback.'],
1307
1348
  gotchas: ['Set data-view="cards" on sf-table-wrap for the responsive card fallback.', 'Do not put interactive controls inside a stretched row link.'],
1308
1349
  markup: {
1309
- html: `<div class="sf-table-wrap" data-view="cards">
1350
+ html: `<div class="sf-table-wrap" data-view="cards" tabindex="0">
1310
1351
  <table class="sf-data-table">
1311
- <thead><tr><th><button class="sf-table-sort" type="button" aria-sort="ascending">Name</button></th><th>Status</th></tr></thead>
1352
+ <thead><tr><th aria-sort="ascending"><button class="sf-table-sort" type="button">Name</button></th><th>Status</th></tr></thead>
1312
1353
  <tbody><tr data-clickable="true"><td data-label="Name"><a class="sf-link" href="/items/1">Northstar</a></td><td data-label="Status"><span class="sf-status" data-size="sm" data-tone="success">Active</span></td></tr></tbody>
1313
1354
  </table>
1314
1355
  </div>`,
@@ -1345,7 +1386,7 @@ export default function DialogExample() {
1345
1386
  html: `<dialog class="sf-dialog" data-layout="form" id="edit-dialog" aria-labelledby="edit-title">
1346
1387
  <header class="sf-dialog__header"><h2 id="edit-title">Edit record</h2></header>
1347
1388
  <form class="sf-form sf-dialog__body" id="edit-form"><label class="sf-field"><span class="sf-label">Name</span><input class="sf-input" name="name"></label></form>
1348
- <footer class="sf-dialog__footer"><button class="sf-button sf-button--outline" formmethod="dialog">Cancel</button><button class="sf-button" form="edit-form">Save</button></footer>
1389
+ <footer class="sf-dialog__footer"><button class="sf-button sf-button--outline" type="submit" form="edit-form" formmethod="dialog">Cancel</button><button class="sf-button" type="submit" form="edit-form">Save</button></footer>
1349
1390
  </dialog>`,
1350
1391
  },
1351
1392
  keywords: ['dialog', 'modal', 'form', 'drawer', 'actions', 'footer'],
@@ -1439,7 +1480,7 @@ export default function DialogExample() {
1439
1480
  a11y: ['Use a caption or labelled section.', 'Use real table headers.', 'Do not replace table markup with divs for tabular data.'],
1440
1481
  gotchas: ['Wrap wide tables in sf-table-wrap.', 'Use sf-status for display only; keep actual status text in the element.'],
1441
1482
  markup: {
1442
- html: `<div class="sf-table-wrap">
1483
+ html: `<div class="sf-table-wrap" tabindex="0">
1443
1484
  <table class="sf-data-table" aria-label="Customers">
1444
1485
  <thead><tr><th>Customer</th><th>Status</th><th>Plan</th><th>Updated</th></tr></thead>
1445
1486
  <tbody>
@@ -1493,6 +1534,132 @@ export default function DialogExample() {
1493
1534
  },
1494
1535
  keywords: ['saas', 'settings', 'section', 'billing', 'team', 'security', 'account', 'detail'],
1495
1536
  },
1537
+ {
1538
+ id: 'switch-control',
1539
+ name: 'Binary switch control',
1540
+ whenToUse: 'A setting that takes effect as a strict on or off value and has a stable visible label.',
1541
+ classes: ['sf-switch', 'sf-switch__control', 'sf-switch__label'],
1542
+ requiresJs: false,
1543
+ requiresJsNotes: 'The native checkbox owns keyboard and form behavior. App logic is only needed for immediate persistence.',
1544
+ a11y: ['Keep the visible label stable when state changes.', 'Use checked for state and disabled when unavailable.', 'Do not use indeterminate or mixed state for a switch.'],
1545
+ gotchas: ['Checkboxes have no readonly state; use disabled, or implement a separately explained app-owned restriction.', 'Place the control span immediately after the checkbox.'],
1546
+ markup: {
1547
+ html: `<label class="sf-switch"><input type="checkbox" role="switch" name="notifications" checked><span class="sf-switch__control" aria-hidden="true"></span><span class="sf-switch__label">Email notifications</span></label>`,
1548
+ },
1549
+ keywords: ['switch', 'toggle', 'settings', 'binary', 'checkbox', 'on', 'off'],
1550
+ },
1551
+ {
1552
+ id: 'input-group',
1553
+ name: 'Input group',
1554
+ whenToUse: 'Text inputs with a unit, currency prefix, domain suffix, or compact inline action.',
1555
+ classes: ['sf-field', 'sf-label', 'sf-input-group', 'sf-input-group__leading', 'sf-input', 'sf-input-group__trailing', 'sf-input-group__action'],
1556
+ requiresJs: false,
1557
+ requiresJsNotes: 'The group is CSS-only. Inline action behavior belongs to the app.',
1558
+ a11y: ['Keep one explicit label for the input.', 'Mark decorative prefixes and suffixes aria-hidden.', 'Give inline actions an accessible name.'],
1559
+ gotchas: ['Use sf-search for ordinary search fields.', 'Keep exactly one flexible form control in each group.'],
1560
+ markup: {
1561
+ html: `<label class="sf-field"><span class="sf-label">Monthly budget</span><span class="sf-input-group"><span class="sf-input-group__leading" aria-hidden="true">£</span><input class="sf-input" name="budget" inputmode="decimal"><span class="sf-input-group__trailing" aria-hidden="true">GBP</span></span></label>`,
1562
+ },
1563
+ keywords: ['input', 'group', 'prefix', 'suffix', 'currency', 'unit', 'action'],
1564
+ },
1565
+ {
1566
+ id: 'native-file-and-range',
1567
+ name: 'Native file and range inputs',
1568
+ whenToUse: 'File selection and bounded numeric values that can use browser-native controls.',
1569
+ classes: ['sf-form', 'sf-field', 'sf-label', 'sf-help', 'sf-file-input', 'sf-range'],
1570
+ requiresJs: false,
1571
+ requiresJsNotes: 'Native inputs submit normally. Upload processing, previews, and live range value output belong to the app.',
1572
+ a11y: ['Label both inputs.', 'Describe accepted file types in visible help text.', 'Provide min, max and a visible current value for range controls.'],
1573
+ gotchas: ['File picker presentation varies by browser.', 'Do not use range where an exact typed value is required.'],
1574
+ markup: {
1575
+ html: `<div class="sf-form"><label class="sf-field"><span class="sf-label">Supporting document</span><input class="sf-file-input" type="file" name="document" accept=".pdf,.doc,.docx"><span class="sf-help">PDF or Word, up to 10 MB.</span></label><label class="sf-field"><span class="sf-label">Team size: <output for="team-size">12</output></span><input class="sf-range" id="team-size" name="team_size" type="range" min="1" max="50" value="12"></label></div>`,
1576
+ },
1577
+ keywords: ['file', 'upload', 'range', 'slider', 'input', 'native'],
1578
+ },
1579
+ {
1580
+ id: 'segmented-control',
1581
+ name: 'Segmented single choice',
1582
+ whenToUse: 'A small set of mutually exclusive form choices displayed as adjacent options.',
1583
+ classes: ['sf-segmented-control', 'sf-segmented-control__option', 'sf-segmented-control__label'],
1584
+ requiresJs: false,
1585
+ requiresJsNotes: 'Native radio inputs own keyboard, form, and checked state.',
1586
+ a11y: ['Use a fieldset and legend to name the choice.', 'Keep every option label visible.', 'Use tabs for panel navigation and switches for independent on/off settings.'],
1587
+ gotchas: ['All radios must share a name.', 'Keep the number of options small enough to scan.'],
1588
+ markup: {
1589
+ html: `<fieldset class="sf-fieldset"><legend>Billing cycle</legend><div class="sf-segmented-control"><label class="sf-segmented-control__option"><input type="radio" name="cycle" value="monthly" checked><span class="sf-segmented-control__label">Monthly</span></label><label class="sf-segmented-control__option"><input type="radio" name="cycle" value="annual"><span class="sf-segmented-control__label">Annual</span></label></div></fieldset>`,
1590
+ },
1591
+ keywords: ['segmented', 'radio', 'choice', 'single', 'options', 'billing'],
1592
+ },
1593
+ {
1594
+ id: 'loading-button',
1595
+ name: 'Loading button',
1596
+ whenToUse: 'A compact pending state for an action while the app completes asynchronous work.',
1597
+ classes: ['sf-button', 'sf-spinner', 'sr-only'],
1598
+ requiresJs: true,
1599
+ requiresJsNotes: 'The app owns pending state, prevents activation while aria-disabled, updates visible text, and announces completion or failure.',
1600
+ a11y: ['Set aria-busy="true" while pending.', 'Keep a visible pending label such as Saving… or expose an sr-only status.', 'Announce the result in a nearby live region.'],
1601
+ gotchas: ['A spinner alone is not an accessible status.', 'aria-disabled keeps the button focusable but does not prevent activation; the app must guard the handler.', 'Use native disabled instead when removing the action from focus order is preferable.'],
1602
+ markup: {
1603
+ html: `<button class="sf-button" type="submit" aria-busy="true" aria-disabled="true"><span class="sf-spinner" aria-hidden="true"></span><span>Saving…</span></button><p class="sr-only" role="status" aria-live="polite">Saving changes</p>`,
1604
+ },
1605
+ keywords: ['loading', 'spinner', 'button', 'busy', 'pending', 'saving'],
1606
+ },
1607
+ {
1608
+ id: 'searchable-select',
1609
+ name: 'Searchable select shell',
1610
+ whenToUse: 'A large option set that needs app-owned filtering and complete combobox keyboard behavior.',
1611
+ classes: ['sf-field', 'sf-label', 'sf-combobox', 'sf-input', 'sf-combobox__listbox', 'sf-combobox__option', 'sf-combobox__empty'],
1612
+ requiresJs: true,
1613
+ requiresJsNotes: 'The app must implement the ARIA combobox contract, active option, filtering, selection, focus, Escape, and outside-click behavior. Synced Flow supplies presentation only.',
1614
+ a11y: ['Follow the ARIA combobox pattern completely.', 'Keep aria-expanded, aria-controls, aria-activedescendant and aria-selected synchronized.', 'Test with keyboard and assistive technology.'],
1615
+ gotchas: ['This shell is not a working combobox without app code.', 'Prefer native select for short option lists.'],
1616
+ markup: {
1617
+ html: `<div class="sf-field"><label class="sf-label" for="customer-search">Customer</label><div class="sf-combobox"><input class="sf-input" id="customer-search" role="combobox" aria-autocomplete="list" aria-expanded="true" aria-controls="customer-options" aria-activedescendant="customer-option-2"><ul class="sf-combobox__listbox" id="customer-options" role="listbox"><li class="sf-combobox__option" id="customer-option-1" role="option" aria-selected="false">Northstar Labs</li><li class="sf-combobox__option" id="customer-option-2" role="option" aria-selected="true">Willow Group</li></ul></div></div>`,
1618
+ },
1619
+ keywords: ['combobox', 'searchable', 'select', 'autocomplete', 'listbox', 'customer'],
1620
+ },
1621
+ {
1622
+ id: 'bulk-actions-toolbar',
1623
+ name: 'Bulk actions toolbar',
1624
+ whenToUse: 'A table or list selection that exposes actions and a clear selected-item count.',
1625
+ classes: ['sf-bulk-actions', 'sf-bulk-actions__count', 'sf-bulk-actions__controls', 'sf-button', 'sf-button--sm', 'sf-button--destructive'],
1626
+ requiresJs: true,
1627
+ requiresJsNotes: 'The app owns row selection, selected count, action availability, confirmation, and focus after updates.',
1628
+ a11y: ['Announce selection count changes in a polite live region.', 'Name destructive actions clearly.', 'Keep selection checkboxes associated with their rows.'],
1629
+ gotchas: ['Hide the toolbar when nothing is selected.', 'Do not infer selection from row colour alone.'],
1630
+ markup: {
1631
+ html: `<div class="sf-bulk-actions" aria-label="Bulk actions"><p class="sf-bulk-actions__count" aria-live="polite">3 customers selected</p><div class="sf-bulk-actions__controls"><button class="sf-button sf-button--sm" type="button">Assign owner</button><button class="sf-button sf-button--destructive sf-button--sm" type="button">Delete selected</button></div></div>`,
1632
+ },
1633
+ keywords: ['bulk', 'actions', 'toolbar', 'selection', 'table', 'list'],
1634
+ },
1635
+ {
1636
+ id: 'complete-pagination',
1637
+ name: 'Complete pagination',
1638
+ whenToUse: 'Server or app pagination that needs result context, page links, page size, and direct page entry.',
1639
+ classes: ['sf-pagination', 'sf-pagination__summary', 'sf-pagination__controls', 'sf-pagination__pages', 'sf-pagination__jump', 'sf-input', 'sf-select', 'sf-button'],
1640
+ requiresJs: false,
1641
+ requiresJsNotes: 'Links and GET forms work without JavaScript. Client-side data grids may synchronize the same controls with app state.',
1642
+ a11y: ['Label the navigation and current page.', 'Use a real label for page size and page entry.', 'Announce updated result context when content changes dynamically.'],
1643
+ gotchas: ['Keep URL parameters shareable for server pagination.', 'Validate page numbers on the server or in the app.'],
1644
+ markup: {
1645
+ html: `<nav class="sf-pagination" data-layout="complete" aria-label="Customer pages"><p class="sf-pagination__summary">Showing 21–40 of 126 customers</p><div class="sf-pagination__controls"><div class="sf-pagination__pages"><a href="?page=1">Previous</a><a href="?page=1">1</a><span aria-current="page">2</span><a href="?page=3">3</a><a href="?page=3">Next</a></div><form class="sf-pagination__jump" method="get"><label for="page-size">Rows</label><select class="sf-select" id="page-size" name="size"><option>20</option><option>50</option></select><label for="page-number">Page</label><input class="sf-input" id="page-number" name="page" type="number" min="1" max="7" value="2"><button class="sf-button sf-button--sm" type="submit">Go</button></form></div></nav>`,
1646
+ },
1647
+ keywords: ['pagination', 'pages', 'page size', 'jump', 'results', 'table'],
1648
+ },
1649
+ {
1650
+ id: 'multi-step-form',
1651
+ name: 'Multi-step form',
1652
+ whenToUse: 'Long forms that need clear progress, a current step, completed steps, and predictable navigation.',
1653
+ classes: ['sf-stepper', 'sf-stepper__step', 'sf-stepper__marker', 'sf-stepper__label', 'sf-form', 'sf-wizard-actions', 'sf-button', 'sf-button--outline'],
1654
+ requiresJs: true,
1655
+ requiresJsNotes: 'The app owns step validation, persistence, navigation, focus placement, and URL or history state.',
1656
+ a11y: ['Use an ordered list and aria-current="step".', 'Prefix completed step labels with sr-only text such as "Completed:" so completion is not visual-only.', 'Move focus to the new step heading after navigation.', 'Preserve entered data and expose validation before advancing.'],
1657
+ gotchas: ['Do not make step markers tabs unless they control tab panels with full tab behavior.', 'Allow review and correction before final submission.'],
1658
+ markup: {
1659
+ html: `<nav aria-label="Application progress"><ol class="sf-stepper"><li class="sf-stepper__step" data-complete="true"><span class="sf-stepper__marker" aria-hidden="true">✓</span><span class="sf-stepper__label"><span class="sr-only">Completed: </span>Account</span></li><li class="sf-stepper__step" aria-current="step"><span class="sf-stepper__marker" aria-hidden="true"></span><span class="sf-stepper__label">Profile</span></li><li class="sf-stepper__step"><span class="sf-stepper__marker" aria-hidden="true"></span><span class="sf-stepper__label">Review</span></li></ol></nav><form class="sf-form" aria-labelledby="profile-step-title"><h2 id="profile-step-title">Profile</h2><label class="sf-field"><span class="sf-label">Role</span><input class="sf-input" name="role"></label><div class="sf-wizard-actions"><button class="sf-button sf-button--outline" type="button">Back</button><button class="sf-button" type="submit">Continue</button></div></form>`,
1660
+ },
1661
+ keywords: ['stepper', 'wizard', 'multi-step', 'form', 'onboarding', 'progress'],
1662
+ },
1496
1663
  {
1497
1664
  id: 'contact-form',
1498
1665
  name: 'Accessible contact form',
@@ -1593,7 +1760,7 @@ function getPublicRecipes() {
1593
1760
  <button class="sf-nav__link" type="submit" formaction="#contact" popovertarget="portfolio-mobile-menu" popovertargetaction="hide">Contact</button>
1594
1761
  </form>
1595
1762
  </nav>
1596
- <main class="sf-scroll-viewport" data-snap="mandatory">
1763
+ <main class="sf-scroll-viewport" data-snap="mandatory" tabindex="0">
1597
1764
  <section class="sf-scroll-panel" id="intro">
1598
1765
  <div class="sf-container sf-stack">
1599
1766
  <p class="sf-kicker">Selected work</p>
@@ -1733,7 +1900,7 @@ function getPublicRecipes() {
1733
1900
  <button class="sf-button" type="submit">Apply</button>
1734
1901
  </form>
1735
1902
  <div class="sf-cluster" aria-label="Active filters"><span class="sf-badge">Status: Active <a class="sf-link" href="/app/activity" aria-label="Remove status filter">Remove</a></span><a class="sf-button sf-button--ghost sf-button--sm" href="/app/activity">Clear filters</a></div>
1736
- <div class="sf-table-wrap">
1903
+ <div class="sf-table-wrap" tabindex="0">
1737
1904
  <table class="sf-data-table" aria-label="Recent activity">
1738
1905
  <thead><tr><th>Item</th><th>Status</th><th>Value</th><th>Date</th></tr></thead>
1739
1906
  <tbody>
@@ -1746,7 +1913,7 @@ function getPublicRecipes() {
1746
1913
  </article>
1747
1914
  <article class="sf-card sf-stack sf-detail-panel" id="activity">
1748
1915
  <div class="sf-repel"><h2 class="sf-card__title sf-font-sans">Recent customers</h2><a class="sf-link" href="/app/customers">View all</a></div>
1749
- <div class="sf-skeleton" aria-hidden="true">Loading customer details</div>
1916
+ <div class="sf-skeleton" aria-hidden="true"></div><span class="sr-only">Loading customer details</span>
1750
1917
  <ul class="sf-list-reset sf-data-list">
1751
1918
  <li><span><strong>Northstar Labs</strong><br><span class="sf-text-muted">Growth workspace</span></span><span class="sf-status" data-tone="success">Healthy</span></li>
1752
1919
  <li><span><strong>Willow Group</strong><br><span class="sf-text-muted">Operations workspace</span></span><span class="sf-status" data-tone="warning">Needs review</span></li>
@@ -1769,6 +1936,36 @@ function getPublicRecipes() {
1769
1936
  </section>
1770
1937
  </main>
1771
1938
  </div>`,
1939
+ },
1940
+ {
1941
+ id: 'app-settings-workflow',
1942
+ name: 'Application settings workflow',
1943
+ whenToUse: 'Production app settings and onboarding forms that need richer native controls and explicit progress.',
1944
+ sections: ['settings-section', 'input-group', 'switch-control', 'segmented-control', 'native-file-and-range', 'searchable-select', 'loading-button', 'multi-step-form'],
1945
+ classes: ['sf-skip-link', 'sf-section', 'sf-container', 'sf-split', 'sf-card', 'sf-stack', 'sf-form', 'sf-field', 'sf-label', 'sf-help', 'sf-input', 'sf-input-group', 'sf-input-group__leading', 'sf-switch', 'sf-switch__control', 'sf-switch__label', 'sf-file-input', 'sf-range', 'sf-segmented-control', 'sf-segmented-control__option', 'sf-segmented-control__label', 'sf-combobox', 'sf-combobox__listbox', 'sf-combobox__option', 'sf-stepper', 'sf-stepper__step', 'sf-stepper__marker', 'sf-stepper__label', 'sf-wizard-actions', 'sf-button', 'sf-spinner'],
1946
+ keywords: ['app', 'settings', 'workflow', 'onboarding', 'form', 'switch', 'input group', 'file', 'range', 'segmented', 'combobox', 'stepper', 'wizard', 'loading'],
1947
+ markup: `<a class="sf-skip-link" href="#settings-main">Skip to settings</a>
1948
+ <main class="sf-section" id="settings-main">
1949
+ <div class="sf-container sf-split">
1950
+ <nav aria-label="Setup progress">
1951
+ <ol class="sf-stepper">
1952
+ <li class="sf-stepper__step" data-complete="true"><span class="sf-stepper__marker" aria-hidden="true">✓</span><span class="sf-stepper__label"><span class="sr-only">Completed: </span>Workspace</span></li>
1953
+ <li class="sf-stepper__step" aria-current="step"><span class="sf-stepper__marker" aria-hidden="true"></span><span class="sf-stepper__label">Preferences</span></li>
1954
+ <li class="sf-stepper__step"><span class="sf-stepper__marker" aria-hidden="true"></span><span class="sf-stepper__label">Review</span></li>
1955
+ </ol>
1956
+ </nav>
1957
+ <form class="sf-card sf-form" aria-labelledby="preferences-title">
1958
+ <header class="sf-stack"><p class="sf-kicker">Step 2 of 3</p><h1 id="preferences-title">Workspace preferences</h1></header>
1959
+ <label class="sf-field"><span class="sf-label">Workspace URL</span><span class="sf-input-group"><span class="sf-input-group__leading" aria-hidden="true">https://</span><input class="sf-input" name="workspace" autocomplete="organization"><span class="sf-input-group__trailing" aria-hidden="true">.example.com</span></span></label>
1960
+ <fieldset class="sf-fieldset"><legend>Billing cycle</legend><div class="sf-segmented-control"><label class="sf-segmented-control__option"><input type="radio" name="cycle" value="monthly" checked><span class="sf-segmented-control__label">Monthly</span></label><label class="sf-segmented-control__option"><input type="radio" name="cycle" value="annual"><span class="sf-segmented-control__label">Annual</span></label></div></fieldset>
1961
+ <label class="sf-switch"><input type="checkbox" role="switch" name="weekly_summary" checked><span class="sf-switch__control" aria-hidden="true"></span><span class="sf-switch__label">Email weekly summary</span></label>
1962
+ <label class="sf-field"><span class="sf-label" id="settings-team-size-label">Team size: <output for="settings-team-size">12</output></span><input class="sf-range" id="settings-team-size" type="range" name="team_size" min="1" max="50" value="12" aria-labelledby="settings-team-size-label"></label>
1963
+ <label class="sf-field"><span class="sf-label">Brand mark</span><input class="sf-file-input" type="file" name="brand_mark" accept="image/png,image/svg+xml"><span class="sf-help">PNG or SVG.</span></label>
1964
+ <div class="sf-field"><label class="sf-label" for="owner-search">Workspace owner</label><div class="sf-combobox"><input class="sf-input" id="owner-search" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="owner-options"><ul class="sf-combobox__listbox" id="owner-options" role="listbox" hidden><li class="sf-combobox__option" role="option">Alex Morgan</li></ul></div><span class="sf-help">The app must implement combobox keyboard and state behavior.</span></div>
1965
+ <div class="sf-wizard-actions"><button class="sf-button sf-button--outline" type="button">Back</button><button class="sf-button" type="submit"><span class="sf-spinner" aria-hidden="true" hidden></span><span>Continue</span></button></div>
1966
+ </form>
1967
+ </div>
1968
+ </main>`,
1772
1969
  },
1773
1970
  {
1774
1971
  id: 'agency-home',
@@ -1823,7 +2020,7 @@ function getPublicRecipes() {
1823
2020
  <figure class="sf-figure"><div class="sf-frame"></div><figcaption class="sf-caption">Use real project imagery here.</figcaption></figure>
1824
2021
  <h2>Start with primitives</h2>
1825
2022
  <p>Use semantic colours, fluid spacing, and named components before adding custom CSS.</p>
1826
- <div class="sf-table-wrap"><table><thead><tr><th>Layer</th><th>Use</th></tr></thead><tbody><tr><td>Tokens</td><td>Brand decisions</td></tr><tr><td>Components</td><td>Common UI</td></tr></tbody></table></div>
2023
+ <div class="sf-table-wrap" tabindex="0"><table><thead><tr><th>Layer</th><th>Use</th></tr></thead><tbody><tr><td>Tokens</td><td>Brand decisions</td></tr><tr><td>Components</td><td>Common UI</td></tr></tbody></table></div>
1827
2024
  </article>
1828
2025
  </main>`,
1829
2026
  },
@@ -2054,6 +2251,7 @@ function runPattern() {
2054
2251
 
2055
2252
  function patternMarkupFor(pattern, framework = 'html') {
2056
2253
  if (typeof pattern.markup === 'string') return pattern.markup
2254
+ if (framework === 'react') return pattern.markup?.react ?? pattern.markup?.next ?? pattern.markup?.html ?? ''
2057
2255
  return pattern.markup?.[framework] ?? pattern.markup?.html ?? ''
2058
2256
  }
2059
2257
 
@@ -2339,25 +2537,60 @@ ${indentMarkup(htmlToJsx(markup), 6)}
2339
2537
  }
2340
2538
 
2341
2539
  function htmlToJsx(markup) {
2540
+ const voidElements = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr'])
2541
+ const attributeNames = new Map([
2542
+ ['class', 'className'],
2543
+ ['for', 'htmlFor'],
2544
+ ['autocomplete', 'autoComplete'],
2545
+ ['inputmode', 'inputMode'],
2546
+ ['readonly', 'readOnly'],
2547
+ ['tabindex', 'tabIndex'],
2548
+ ['formaction', 'formAction'],
2549
+ ['formmethod', 'formMethod'],
2550
+ ['formenctype', 'formEncType'],
2551
+ ['formnovalidate', 'formNoValidate'],
2552
+ ['formtarget', 'formTarget'],
2553
+ ['accept-charset', 'acceptCharset'],
2554
+ ['charset', 'charSet'],
2555
+ ['colspan', 'colSpan'],
2556
+ ['rowspan', 'rowSpan'],
2557
+ ['maxlength', 'maxLength'],
2558
+ ['minlength', 'minLength'],
2559
+ ['contenteditable', 'contentEditable'],
2560
+ ['crossorigin', 'crossOrigin'],
2561
+ ['referrerpolicy', 'referrerPolicy'],
2562
+ ['usemap', 'useMap'],
2563
+ ['datetime', 'dateTime'],
2564
+ ['popovertarget', 'popoverTarget'],
2565
+ ['popovertargetaction', 'popoverTargetAction'],
2566
+ ['commandfor', 'commandFor'],
2567
+ ['stroke-linecap', 'strokeLinecap'],
2568
+ ['stroke-linejoin', 'strokeLinejoin'],
2569
+ ['stroke-width', 'strokeWidth'],
2570
+ ['fill-rule', 'fillRule'],
2571
+ ['clip-rule', 'clipRule'],
2572
+ ])
2573
+
2342
2574
  return markup
2343
- .replace(/\bclass=/g, 'className=')
2344
- .replace(/\bfor=/g, 'htmlFor=')
2345
- .replace(/\bautocomplete=/g, 'autoComplete=')
2346
- .replace(/\breadonly=/g, 'readOnly=')
2347
- .replace(/\btabindex=/g, 'tabIndex=')
2348
- .replace(/\bformaction=/g, 'formAction=')
2349
- .replace(/\bpopoverTarget=/g, 'popoverTarget=')
2350
- .replace(/\bpopovertarget=/g, 'popoverTarget=')
2351
- .replace(/\bpopoverTargetAction=/g, 'popoverTargetAction=')
2352
- .replace(/\bpopovertargetaction=/g, 'popoverTargetAction=')
2353
- .replace(/\bcommandfor=/g, 'commandFor=')
2354
- .replace(/\bstroke-linecap=/g, 'strokeLinecap=')
2355
- .replace(/\bstroke-linejoin=/g, 'strokeLinejoin=')
2356
- .replace(/\bstroke-width=/g, 'strokeWidth=')
2357
- .replace(/<!--/g, '{/*')
2358
- .replace(/-->/g, '*/}')
2359
- .replace(/<input([^>/]*?)>/g, '<input$1 />')
2360
- .replace(/<br>/g, '<br />')
2575
+ .replace(/<!--([\s\S]*?)-->/g, '{/*$1*/}')
2576
+ .replace(/<([A-Za-z][\w:-]*)(\s[^<>]*?)?\s*(\/?)>/g, (match, rawTagName, rawAttributes = '', selfClosing = '') => {
2577
+ const tagName = rawTagName.toLowerCase()
2578
+ let attributes = rawAttributes.replace(/\s+$/, '')
2579
+
2580
+ for (const [htmlName, jsxName] of attributeNames) {
2581
+ attributes = attributes.replace(new RegExp(`(\\s)${htmlName}(?=\\s*=)`, 'gi'), `$1${jsxName}`)
2582
+ }
2583
+
2584
+ attributes = attributes.replace(/(\s)checked(?=\s|$)/gi, '$1defaultChecked')
2585
+ attributes = attributes.replace(/(\s)popover(?=\s|$)/gi, '$1popover=""')
2586
+ attributes = attributes.replace(/(\s)(rows|cols|size|span|tabIndex)=["'](\d+)["']/gi, '$1$2={$3}')
2587
+ if (['input', 'select', 'textarea'].includes(tagName) && !/\stype\s*=\s*["'](?:checkbox|radio)["']/i.test(attributes)) {
2588
+ attributes = attributes.replace(/(\s)value(?=\s*=)/gi, '$1defaultValue')
2589
+ }
2590
+
2591
+ const close = voidElements.has(tagName) || selfClosing ? ' />' : '>'
2592
+ return `<${rawTagName}${attributes}${close}`
2593
+ })
2361
2594
  }
2362
2595
 
2363
2596
  function indentMarkup(markup, spaces) {
@@ -4795,6 +5028,8 @@ function buildComponentCss() {
4795
5028
 
4796
5029
  .sf-data-table :where(th button, .sf-table-sort) {
4797
5030
  align-items: center;
5031
+ background: transparent;
5032
+ border: 0;
4798
5033
  color: inherit;
4799
5034
  display: inline-flex;
4800
5035
  font: inherit;
@@ -4803,14 +5038,14 @@ function buildComponentCss() {
4803
5038
  padding: 0;
4804
5039
  }
4805
5040
 
4806
- .sf-data-table :where(th button, .sf-table-sort)[aria-sort]::after {
5041
+ .sf-data-table th[aria-sort] :where(button, .sf-table-sort)::after {
4807
5042
  color: var(--color-muted-foreground);
4808
5043
  content: "\\2195";
4809
5044
  font-size: var(--step--2);
4810
5045
  }
4811
5046
 
4812
- .sf-data-table :where(th button, .sf-table-sort)[aria-sort="ascending"]::after { content: "\\2191"; }
4813
- .sf-data-table :where(th button, .sf-table-sort)[aria-sort="descending"]::after { content: "\\2193"; }
5047
+ .sf-data-table th[aria-sort="ascending"] :where(button, .sf-table-sort)::after { content: "\\2191"; }
5048
+ .sf-data-table th[aria-sort="descending"] :where(button, .sf-table-sort)::after { content: "\\2193"; }
4814
5049
 
4815
5050
  .sf-data-table tr[data-clickable="true"] {
4816
5051
  cursor: pointer;
@@ -4885,7 +5120,7 @@ function buildComponentCss() {
4885
5120
  }
4886
5121
 
4887
5122
  .sf-status::before {
4888
- background: currentColor;
5123
+ background: var(--status-dot, currentColor);
4889
5124
  block-size: .42em;
4890
5125
  border-radius: var(--radius-full);
4891
5126
  content: "";
@@ -4895,25 +5130,29 @@ function buildComponentCss() {
4895
5130
  .sf-status[data-tone="success"] {
4896
5131
  --status-bg: var(--sf-colour-success-soft, oklch(62% 0.13 150 / 0.12));
4897
5132
  --status-border: color-mix(in oklch, var(--sf-colour-success, oklch(62% 0.13 150)) 34%, transparent);
4898
- --status-color: color-mix(in oklch, var(--sf-colour-success, oklch(62% 0.13 150)) 76%, var(--color-foreground));
5133
+ --status-color: var(--color-foreground);
5134
+ --status-dot: var(--sf-colour-success, oklch(62% 0.13 150));
4899
5135
  }
4900
5136
 
4901
5137
  .sf-status[data-tone="warning"] {
4902
5138
  --status-bg: var(--sf-colour-warning-soft, oklch(78% 0.15 82 / 0.16));
4903
5139
  --status-border: color-mix(in oklch, var(--sf-colour-warning, oklch(78% 0.15 82)) 38%, transparent);
4904
- --status-color: color-mix(in oklch, var(--sf-colour-warning, oklch(78% 0.15 82)) 70%, var(--color-foreground));
5140
+ --status-color: var(--color-foreground);
5141
+ --status-dot: color-mix(in oklch, var(--sf-colour-warning, oklch(78% 0.15 82)) 60%, var(--color-foreground));
4905
5142
  }
4906
5143
 
4907
5144
  .sf-status[data-tone="danger"] {
4908
5145
  --status-bg: var(--sf-colour-danger-soft, oklch(56% 0.2 28 / 0.12));
4909
5146
  --status-border: color-mix(in oklch, var(--sf-colour-danger, var(--color-destructive)) 34%, transparent);
4910
- --status-color: color-mix(in oklch, var(--sf-colour-danger, var(--color-destructive)) 76%, var(--color-foreground));
5147
+ --status-color: var(--color-foreground);
5148
+ --status-dot: var(--sf-colour-danger, var(--color-destructive));
4911
5149
  }
4912
5150
 
4913
5151
  .sf-status[data-tone="info"] {
4914
5152
  --status-bg: var(--sf-colour-info-soft, oklch(70% 0.12 205 / 0.14));
4915
5153
  --status-border: color-mix(in oklch, var(--sf-colour-info, oklch(70% 0.12 205)) 34%, transparent);
4916
- --status-color: color-mix(in oklch, var(--sf-colour-info, oklch(70% 0.12 205)) 76%, var(--color-foreground));
5154
+ --status-color: var(--color-foreground);
5155
+ --status-dot: color-mix(in oklch, var(--sf-colour-info, oklch(70% 0.12 205)) 68%, var(--color-foreground));
4917
5156
  }
4918
5157
 
4919
5158
  .sf-data-list {
@@ -4961,9 +5200,16 @@ function buildComponentCss() {
4961
5200
  background: var(--color-surface-alt);
4962
5201
  }
4963
5202
 
5203
+ .sf-form {
5204
+ display: grid;
5205
+ gap: var(--form-gap, var(--space-s));
5206
+ grid-template-columns: minmax(0, 1fr);
5207
+ }
5208
+
4964
5209
  .sf-field {
4965
5210
  display: grid;
4966
5211
  gap: var(--space-2xs);
5212
+ grid-template-columns: minmax(0, 1fr);
4967
5213
  }
4968
5214
 
4969
5215
  .sf-form[data-density="compact"],
@@ -5026,6 +5272,130 @@ function buildComponentCss() {
5026
5272
  outline: 0.125rem solid color-mix(in oklch, var(--color-ring) 24%, transparent);
5027
5273
  outline-offset: 0;
5028
5274
  }
5275
+
5276
+ .sf-spinner {
5277
+ animation: sf-spinner-rotate .75s linear infinite;
5278
+ block-size: var(--spinner-size, 1em);
5279
+ border: .125em solid color-mix(in oklch, currentColor 28%, transparent);
5280
+ border-block-start-color: currentColor;
5281
+ border-radius: var(--radius-full);
5282
+ display: inline-block;
5283
+ flex: 0 0 auto;
5284
+ inline-size: var(--spinner-size, 1em);
5285
+ }
5286
+
5287
+ @keyframes sf-spinner-rotate { to { transform: rotate(1turn); } }
5288
+
5289
+ .sf-input-group {
5290
+ align-items: stretch;
5291
+ background: var(--color-card);
5292
+ border: 1px solid var(--color-border);
5293
+ border-radius: var(--input-radius, var(--radius-md));
5294
+ display: flex;
5295
+ inline-size: 100%;
5296
+ min-block-size: var(--input-block-size, 2.75rem);
5297
+ overflow: clip;
5298
+ }
5299
+
5300
+ .sf-input-group:focus-within {
5301
+ border-color: var(--color-ring);
5302
+ outline: .125rem solid color-mix(in oklch, var(--color-ring) 24%, transparent);
5303
+ }
5304
+
5305
+ .sf-input-group > .sf-input { background: transparent; border: 0; border-radius: 0; flex: 1 1 0%; inline-size: auto; min-inline-size: 0; outline: 0; }
5306
+ .sf-input-group__leading,
5307
+ .sf-input-group__trailing { align-items: center; color: var(--color-muted-foreground); display: inline-flex; flex: 0 0 auto; padding-inline: var(--space-xs); white-space: nowrap; }
5308
+ .sf-input-group__leading { border-inline-end: 1px solid var(--color-border); }
5309
+ .sf-input-group__trailing,
5310
+ .sf-input-group__action { border-inline-start: 1px solid var(--color-border); }
5311
+ .sf-input-group__action { background: transparent; border-block: 0; border-inline-end: 0; color: var(--color-foreground); font-weight: 700; padding-inline: var(--space-xs); }
5312
+ .sf-input-group[data-invalid="true"],
5313
+ .sf-field[data-invalid="true"] .sf-input-group { border-color: var(--color-destructive, var(--color-danger)); }
5314
+ .sf-input-group:has(:disabled) { background: var(--color-surface-alt); opacity: .72; }
5315
+
5316
+ .sf-file-input {
5317
+ background: var(--color-card);
5318
+ border: 1px solid var(--color-border);
5319
+ border-radius: var(--input-radius, var(--radius-md));
5320
+ color: var(--color-muted-foreground);
5321
+ inline-size: 100%;
5322
+ min-block-size: var(--input-block-size, 2.75rem);
5323
+ padding: var(--space-3xs);
5324
+ }
5325
+
5326
+ .sf-file-input::file-selector-button { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: var(--radius-sm); color: var(--color-foreground); font: inherit; font-weight: 700; margin-inline-end: var(--space-xs); min-block-size: 2.25rem; padding-inline: var(--space-xs); }
5327
+ .sf-range { accent-color: var(--color-primary); inline-size: 100%; min-block-size: var(--input-block-size, 2.75rem); }
5328
+ .sf-range:focus-visible,
5329
+ .sf-file-input:focus-visible { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5330
+ .sf-file-input[aria-invalid="true"],
5331
+ .sf-field[data-invalid="true"] .sf-file-input { border-color: var(--color-destructive, var(--color-danger)); }
5332
+ :where(.sf-file-input, .sf-range):disabled { opacity: .55; }
5333
+
5334
+ .sf-switch { align-items: center; display: inline-grid; gap: var(--space-xs); grid-template-columns: auto 1fr; position: relative; }
5335
+ .sf-switch > input[type="checkbox"],
5336
+ .sf-segmented-control__option > input[type="radio"] { block-size: .0625rem; clip: rect(0 0 0 0); clip-path: inset(50%); inline-size: .0625rem; overflow: hidden; position: absolute; white-space: nowrap; }
5337
+ .sf-switch__control { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: var(--radius-full); block-size: 1.5rem; display: inline-flex; inline-size: 2.75rem; padding: .1875rem; }
5338
+ .sf-switch__control::before { background: var(--color-foreground); border-radius: var(--radius-full); content: ""; inline-size: 1rem; transition: transform var(--duration-fast) var(--ease-standard); }
5339
+ .sf-switch > input:checked + .sf-switch__control { background: var(--color-primary); border-color: var(--color-primary); }
5340
+ .sf-switch > input:checked + .sf-switch__control::before { background: var(--color-primary-foreground); transform: translateX(1.1875rem); }
5341
+ .sf-switch > input:focus-visible + .sf-switch__control { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5342
+ .sf-switch__label { color: var(--color-foreground); font-weight: 700; }
5343
+ .sf-switch:has(input:disabled) { cursor: not-allowed; opacity: .55; }
5344
+
5345
+ .sf-segmented-control { background: var(--color-surface-alt); border: 1px solid var(--color-border); border-radius: var(--radius-md); display: inline-flex; flex-wrap: wrap; gap: var(--space-3xs); padding: var(--space-3xs); }
5346
+ .sf-segmented-control__option { cursor: pointer; position: relative; }
5347
+ .sf-segmented-control__label { align-items: center; border: 1px solid transparent; border-radius: var(--radius-sm); color: var(--color-muted-foreground); display: inline-flex; font-size: var(--step--1); font-weight: 700; min-block-size: 2.25rem; padding-inline: var(--space-xs); }
5348
+ .sf-segmented-control__option > input:checked + .sf-segmented-control__label { background: var(--color-card); border-color: var(--color-border); color: var(--color-foreground); }
5349
+ .sf-segmented-control__option > input:focus + .sf-segmented-control__label { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5350
+ .sf-segmented-control__option:has(input:disabled) { cursor: not-allowed; opacity: .55; }
5351
+
5352
+ .sf-combobox { position: relative; }
5353
+ .sf-combobox__listbox { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-md); box-shadow: var(--shadow-lg); display: grid; gap: var(--space-3xs); inset-block-start: calc(100% + var(--space-3xs)); inset-inline: 0; list-style: none; max-block-size: min(18rem, 50dvh); overflow: auto; padding: var(--space-2xs); position: absolute; z-index: 40; }
5354
+ .sf-combobox__option { border-radius: var(--radius-sm); cursor: pointer; padding: var(--space-2xs) var(--space-xs); }
5355
+ .sf-combobox__option:hover,
5356
+ .sf-combobox__option[aria-selected="true"],
5357
+ .sf-combobox__option[data-active="true"] { background: var(--color-surface-alt); }
5358
+ .sf-combobox__empty { color: var(--color-muted-foreground); padding: var(--space-xs); }
5359
+
5360
+ .sf-bulk-actions { align-items: center; background: color-mix(in oklch, var(--color-primary) 12%, transparent); border: 1px solid color-mix(in oklch, var(--color-primary) 30%, var(--color-border)); border-radius: var(--radius-md); display: flex; flex-wrap: wrap; gap: var(--space-xs); justify-content: space-between; padding: var(--space-xs); }
5361
+ .sf-bulk-actions__count { font-size: var(--step--1); font-weight: 800; }
5362
+ .sf-bulk-actions__controls,
5363
+ .sf-pagination__controls,
5364
+ .sf-pagination__pages,
5365
+ .sf-pagination__jump { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
5366
+
5367
+ .sf-pagination { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-2xs); list-style: none; padding-inline-start: 0; }
5368
+ .sf-pagination[data-layout="complete"] { justify-content: space-between; row-gap: var(--space-xs); }
5369
+ .sf-pagination__summary,
5370
+ .sf-pagination__jump { color: var(--color-muted-foreground); font-size: var(--step--1); }
5371
+ .sf-pagination__jump .sf-input { inline-size: 5rem; }
5372
+
5373
+ .sf-stepper { counter-reset: sf-step; display: grid; gap: var(--space-xs); list-style: none; padding-inline-start: 0; }
5374
+ .sf-stepper__step { align-items: center; color: var(--color-muted-foreground); counter-increment: sf-step; display: grid; gap: var(--space-xs); grid-template-columns: auto 1fr; }
5375
+ .sf-stepper__marker { align-items: center; border: 1px solid var(--color-border); border-radius: var(--radius-full); block-size: 2rem; display: inline-flex; font-size: var(--step--1); font-weight: 800; inline-size: 2rem; justify-content: center; }
5376
+ .sf-stepper__marker:empty::before { content: counter(sf-step); }
5377
+ .sf-stepper__step[aria-current="step"] { color: var(--color-foreground); }
5378
+ .sf-stepper__step[aria-current="step"] .sf-stepper__marker { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary-foreground); }
5379
+ .sf-stepper__label { font-weight: 700; }
5380
+ .sf-wizard-actions { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-xs); justify-content: space-between; }
5381
+
5382
+ @media (prefers-reduced-motion: reduce) {
5383
+ .sf-spinner { animation: none; border-color: currentColor; border-inline-end-color: transparent; }
5384
+ .sf-switch__control::before { transition: none; }
5385
+ }
5386
+
5387
+ @media (forced-colors: active) {
5388
+ :where(.sf-input-group, .sf-file-input, .sf-combobox__listbox, .sf-bulk-actions) { border-color: ButtonText; forced-color-adjust: auto; }
5389
+ :where(.sf-switch__control, .sf-segmented-control__label, .sf-stepper__marker) { background: Canvas; border-color: ButtonText; color: ButtonText; forced-color-adjust: none; }
5390
+ .sf-switch > input:focus-visible + .sf-switch__control,
5391
+ .sf-segmented-control__option > input:focus + .sf-segmented-control__label { outline: .125rem solid Highlight; outline-offset: .125rem; }
5392
+ .sf-switch > input:checked + .sf-switch__control,
5393
+ .sf-segmented-control__option > input:checked + .sf-segmented-control__label,
5394
+ .sf-stepper__step[aria-current="step"] .sf-stepper__marker { background: Highlight; border-color: Highlight; color: HighlightText; }
5395
+ .sf-switch__control::before { background: ButtonText; }
5396
+ .sf-switch > input:checked + .sf-switch__control::before { background: HighlightText; }
5397
+ .sf-stepper__step[data-complete="true"] .sf-stepper__marker { background: Canvas; border-color: LinkText; color: LinkText; }
5398
+ }
5029
5399
  }`
5030
5400
  }
5031
5401