@syncedco/flow 0.3.2 → 0.5.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.
@@ -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) {
@@ -3385,6 +3618,12 @@ function rawDeclarationsFor(base) {
3385
3618
  [/^overflow-x-(hidden|visible|auto|scroll|clip)$/, 'overflow-x'],
3386
3619
  [/^overflow-y-(hidden|visible|auto|scroll|clip)$/, 'overflow-y'],
3387
3620
  [/^object-(cover|contain|fill|none|scale-down)$/, 'object-fit'],
3621
+ [/^object-(center|top|bottom|left|right|left-top|left-bottom|right-top|right-bottom)$/, 'object-position'],
3622
+ [/^bg-(cover|contain|auto)$/, 'background-size'],
3623
+ [/^bg-(center|top|bottom|left|right|left-top|left-bottom|right-top|right-bottom)$/, 'background-position'],
3624
+ [/^bg-(repeat|no-repeat|repeat-x|repeat-y|repeat-round|repeat-space)$/, 'background-repeat'],
3625
+ [/^bg-(fixed|local|scroll)$/, 'background-attachment'],
3626
+ [/^origin-(center|top|bottom|left|right|top-left|top-right|bottom-left|bottom-right)$/, 'transform-origin'],
3388
3627
  [/^whitespace-(normal|nowrap|pre|pre-line|pre-wrap|break-spaces)$/, 'white-space'],
3389
3628
  [/^break-(normal|words|all|keep)$/, 'overflow-wrap'],
3390
3629
  [/^cursor-(pointer|default|not-allowed|grab|grabbing|text)$/, 'cursor'],
@@ -3394,11 +3633,17 @@ function rawDeclarationsFor(base) {
3394
3633
  ]
3395
3634
  for (const [pattern, property] of simpleMaps) {
3396
3635
  const match = base.match(pattern)
3397
- if (match) return [[property, match[1] === 'words' ? 'break-word' : match[1]]]
3636
+ if (match) {
3637
+ if (match[1] === 'words') return [[property, 'break-word']]
3638
+ // Positions are written `left-top` in the class and `left top` in CSS.
3639
+ return [[property, /-position$|-origin$/.test(property) ? match[1].replace('-', ' ') : match[1]]]
3640
+ }
3398
3641
  }
3399
3642
 
3400
3643
  if (base === 'appearance-none') return [['appearance', 'none']]
3401
3644
  if (base === 'resize') return [['resize', 'both']]
3645
+ if (base === 'text-ellipsis') return [['text-overflow', 'ellipsis']]
3646
+ if (base === 'text-clip') return [['text-overflow', 'clip']]
3402
3647
  if (base === 'truncate') return [['overflow', 'hidden'], ['text-overflow', 'ellipsis'], ['white-space', 'nowrap']]
3403
3648
  const lineClamp = base.match(/^line-clamp-(\d+)$/)
3404
3649
  if (lineClamp) return [['overflow', 'hidden'], ['display', '-webkit-box'], ['-webkit-box-orient', 'vertical'], ['-webkit-line-clamp', lineClamp[1]]]
@@ -3446,7 +3691,8 @@ function sideUtility(base, prefixes) {
3446
3691
  const match = base.match(new RegExp(`^-?${prefix}-(.+)$`))
3447
3692
  if (!match) continue
3448
3693
  const negative = base.startsWith('-')
3449
- const value = spacingUnit(match[1]) ?? sizeKeyword(match[1])
3694
+ const axis = prefix === 'top' || prefix === 'bottom' ? 'y' : 'x'
3695
+ const value = spacingUnit(match[1]) ?? sizeKeyword(match[1], axis)
3450
3696
  if (!value) continue
3451
3697
  const resolved = negative ? `calc(${value} * -1)` : value
3452
3698
  if (prefix === 'inset') return [['inset', resolved]]
@@ -3487,18 +3733,22 @@ function spacingUtility(base) {
3487
3733
  'gap-x': ['column-gap'],
3488
3734
  'gap-y': ['row-gap'],
3489
3735
  }
3490
- if (prefix === 'space-x') return [['--sf-space-x', resolved]]
3491
- if (prefix === 'space-y') return [['--sf-space-y', resolved]]
3736
+ if (prefix === 'space-x') return [['margin-inline-start', resolved]]
3737
+ if (prefix === 'space-y') return [['margin-block-start', resolved]]
3492
3738
  return map[prefix].map((property) => [property, resolved])
3493
3739
  }
3494
3740
 
3495
- function sizeKeyword(valueName) {
3496
- const map = {
3741
+ // Keyword sizes. `screen` is swapped for the viewport height on the y axis.
3742
+ const sizeKeywords = {
3497
3743
  auto: 'auto',
3498
3744
  full: '100%',
3499
3745
  screen: '100svw',
3500
3746
  svw: '100svw',
3501
3747
  svh: '100svh',
3748
+ dvw: '100dvw',
3749
+ dvh: '100dvh',
3750
+ lvw: '100lvw',
3751
+ lvh: '100lvh',
3502
3752
  min: 'min-content',
3503
3753
  max: 'max-content',
3504
3754
  fit: 'fit-content',
@@ -3525,15 +3775,21 @@ function sizeKeyword(valueName) {
3525
3775
  '4/5': '80%',
3526
3776
  '1/6': '16.666667%',
3527
3777
  '5/6': '83.333333%',
3528
- }
3529
- return map[valueName] ?? null
3778
+ }
3779
+
3780
+ function sizeKeyword(valueName, axis = 'x') {
3781
+ if (valueName === 'screen' && axis === 'y') return '100svh'
3782
+ return Object.hasOwn(sizeKeywords, valueName) ? sizeKeywords[valueName] : null
3530
3783
  }
3531
3784
 
3532
3785
  function sizingUtility(base) {
3533
3786
  const match = base.match(/^(w|h|min-w|min-h|max-w|max-h|size|basis)-(.+)$/)
3534
3787
  if (!match) return null
3535
3788
  const [, prefix, valueName] = match
3536
- const value = spacingUnit(valueName) ?? sizeKeyword(valueName)
3789
+ if ((prefix === 'max-w' || prefix === 'max-h') && valueName === 'none') return [[prefix === 'max-w' ? 'max-width' : 'max-height', 'none']]
3790
+ if (prefix === 'size' && valueName === 'screen') return [['width', '100svw'], ['height', '100svh']]
3791
+ const axis = prefix === 'h' || prefix === 'min-h' || prefix === 'max-h' ? 'y' : 'x'
3792
+ const value = spacingUnit(valueName) ?? sizeKeyword(valueName, axis)
3537
3793
  if (!value) return null
3538
3794
  const props = {
3539
3795
  w: ['width'],
@@ -3559,6 +3815,7 @@ function flexGridUtility(base) {
3559
3815
  'flex-1': [['flex', '1 1 0%']],
3560
3816
  'flex-auto': [['flex', '1 1 auto']],
3561
3817
  'flex-none': [['flex', 'none']],
3818
+ 'flex-initial': [['flex', '0 1 auto']],
3562
3819
  grow: [['flex-grow', '1']],
3563
3820
  'grow-0': [['flex-grow', '0']],
3564
3821
  shrink: [['flex-shrink', '1']],
@@ -3585,6 +3842,12 @@ function flexGridUtility(base) {
3585
3842
  const placeMap = { center: 'center', start: 'start', end: 'end', stretch: 'stretch' }
3586
3843
  let match = base.match(/^items-(start|end|center|baseline|stretch)$/)
3587
3844
  if (match) return [['align-items', alignMap[match[1]]]]
3845
+ match = base.match(/^justify-items-(start|end|center|stretch)$/)
3846
+ if (match) return [['justify-items', match[1]]]
3847
+ match = base.match(/^justify-self-(auto|start|end|center|stretch)$/)
3848
+ if (match) return [['justify-self', match[1]]]
3849
+ match = base.match(/^order-(\d+|first|last|none)$/)
3850
+ if (match) return [['order', { first: '-9999', last: '9999', none: '0' }[match[1]] ?? match[1]]]
3588
3851
  match = base.match(/^justify-(start|end|center|between|around|evenly|stretch)$/)
3589
3852
  if (match) return [['justify-content', justifyMap[match[1]]]]
3590
3853
  match = base.match(/^content-(start|end|center|between|around|evenly|stretch)$/)
@@ -3603,6 +3866,8 @@ function flexGridUtility(base) {
3603
3866
  match = base.match(/^row-span-(\d+)$/)
3604
3867
  if (match) return [['grid-row', `span ${match[1]} / span ${match[1]}`]]
3605
3868
  if (base === 'col-span-full') return [['grid-column', '1 / -1']]
3869
+ match = base.match(/^(col|row)-(start|end)-(\d+|auto)$/)
3870
+ if (match) return [[`grid-${match[1] === 'col' ? 'column' : 'row'}-${match[2]}`, match[3]]]
3606
3871
  if (base === 'grid-flow-col') return [['grid-auto-flow', 'column']]
3607
3872
  if (base === 'grid-flow-row') return [['grid-auto-flow', 'row']]
3608
3873
 
@@ -3610,10 +3875,11 @@ function flexGridUtility(base) {
3610
3875
  }
3611
3876
 
3612
3877
  function typographyUtility(base) {
3613
- let match = base.match(/^font-(sans|display|mono)$/)
3878
+ let match = base.match(/^font-(sans|serif|display|mono)$/)
3614
3879
  if (match) {
3615
3880
  const map = {
3616
3881
  sans: 'var(--font-inter, var(--sf-font-sans, ui-sans-serif, system-ui, sans-serif))',
3882
+ serif: 'var(--font-serif, var(--sf-font-serif, ui-serif, Georgia, Cambria, "Times New Roman", serif))',
3617
3883
  display: 'var(--font-display, var(--sf-font-display, ui-serif, Georgia, serif))',
3618
3884
  mono: 'var(--font-mono, var(--sf-font-mono, ui-monospace, monospace))',
3619
3885
  }
@@ -3661,22 +3927,33 @@ function colourUtility(base) {
3661
3927
  }
3662
3928
 
3663
3929
  function borderUtility(base) {
3664
- if (base === 'border') return [['border-width', '1px']]
3930
+ // A border width draws nothing without a line style, so width utilities set both.
3931
+ if (base === 'border') return [['border-width', '1px'], ['border-style', 'solid']]
3665
3932
  if (base === 'border-0') return [['border-width', '0']]
3666
- if (base === 'border-2') return [['border-width', '0.125rem']]
3667
- if (base === 'border-t') return [['border-top-width', '1px']]
3668
- if (base === 'border-r') return [['border-right-width', '1px']]
3669
- if (base === 'border-b') return [['border-bottom-width', '1px']]
3670
- if (base === 'border-l') return [['border-left-width', '1px']]
3671
- if (base === 'border-y') return [['border-top-width', '1px'], ['border-bottom-width', '1px']]
3672
- if (base === 'border-x') return [['border-left-width', '1px'], ['border-right-width', '1px']]
3933
+ if (base === 'border-2') return [['border-width', '0.125rem'], ['border-style', 'solid']]
3934
+ if (base === 'border-t') return [['border-top-width', '1px'], ['border-top-style', 'solid']]
3935
+ if (base === 'border-r') return [['border-right-width', '1px'], ['border-right-style', 'solid']]
3936
+ if (base === 'border-b') return [['border-bottom-width', '1px'], ['border-bottom-style', 'solid']]
3937
+ if (base === 'border-l') return [['border-left-width', '1px'], ['border-left-style', 'solid']]
3938
+ if (base === 'border-y') return [['border-top-width', '1px'], ['border-bottom-width', '1px'], ['border-top-style', 'solid'], ['border-bottom-style', 'solid']]
3939
+ if (base === 'border-x') return [['border-left-width', '1px'], ['border-right-width', '1px'], ['border-left-style', 'solid'], ['border-right-style', 'solid']]
3673
3940
  const sideWidth = base.match(/^border-(t|r|b|l)-(\d+)$/)
3674
3941
  if (sideWidth) {
3675
3942
  const side = { t: 'top', r: 'right', b: 'bottom', l: 'left' }[sideWidth[1]]
3676
- return [[`border-${side}-width`, borderWidthUnit(sideWidth[2])]]
3677
- }
3678
- if (base === 'divide-y') return [['--sf-divide-y', '1px']]
3679
- if (base === 'divide-x') return [['--sf-divide-x', '1px']]
3943
+ return [[`border-${side}-width`, borderWidthUnit(sideWidth[2])], [`border-${side}-style`, 'solid']]
3944
+ }
3945
+ const borderStyle = base.match(/^border-(solid|dashed|dotted|double|hidden|none)$/)
3946
+ if (borderStyle) return [['border-style', borderStyle[1]]]
3947
+ const divide = base.match(/^divide-(x|y)(?:-(\d+))?$/)
3948
+ if (divide) {
3949
+ const side = divide[1] === 'y' ? 'block' : 'inline'
3950
+ const width = divide[2] === undefined ? '1px' : borderWidthUnit(divide[2])
3951
+ return [[`border-${side}-start-width`, width], [`border-${side}-start-style`, 'solid']]
3952
+ }
3953
+ const divideStyle = base.match(/^divide-(solid|dashed|dotted|double|none)$/)
3954
+ if (divideStyle) return [['border-style', divideStyle[1]]]
3955
+ const divideColour = base.match(/^divide-(.+)$/)
3956
+ if (divideColour && colourValue(divideColour[1])) return [['border-color', colourValue(divideColour[1])]]
3680
3957
  const roundedSide = base.match(/^rounded-(t|r|b|l|tr|tl|br|bl)-(none|xs|sm|md|lg|xl|2xl|3xl|full)$/)
3681
3958
  if (roundedSide) {
3682
3959
  const radius = `var(--radius-${roundedSide[2]})`
@@ -3714,6 +3991,9 @@ function borderUtility(base) {
3714
3991
  }
3715
3992
 
3716
3993
  function effectUtility(base) {
3994
+ const linear = base.match(/^bg-linear-to-(t|b|l|r|tl|tr|bl|br)$/)
3995
+ if (linear) return effectUtility(`bg-gradient-to-${linear[1]}`)
3996
+ if (base === 'bg-none') return [['background-image', 'none']]
3717
3997
  if (base === 'bg-gradient-to-r') return [['background-image', 'linear-gradient(to right, var(--sf-gradient-stops))']]
3718
3998
  if (base === 'bg-gradient-to-l') return [['background-image', 'linear-gradient(to left, var(--sf-gradient-stops))']]
3719
3999
  if (base === 'bg-gradient-to-t') return [['background-image', 'linear-gradient(to top, var(--sf-gradient-stops))']]
@@ -3735,6 +4015,7 @@ function effectUtility(base) {
3735
4015
 
3736
4016
  let match = base.match(/^opacity-(\d+)$/)
3737
4017
  if (match) return [['opacity', String(Number(match[1]) / 100)]]
4018
+ if (base === 'blur') return [['filter', 'blur(0.5rem)']]
3738
4019
  match = base.match(/^blur-(none|sm|md|lg|xl|2xl|3xl)$/)
3739
4020
  if (match) {
3740
4021
  const map = { none: '0', sm: '0.25rem', md: '0.75rem', lg: '1rem', xl: '1.5rem', '2xl': '2.5rem', '3xl': '4rem' }
@@ -3765,6 +4046,9 @@ function effectUtility(base) {
3765
4046
  if (base === 'transition') return [['transition-property', 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
3766
4047
  if (base === 'transition-all') return [['transition-property', 'all'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
3767
4048
  if (base === 'transition-colors') return [['transition-property', 'color, background-color, border-color, text-decoration-color, fill, stroke'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
4049
+ if (base === 'transition-opacity') return [['transition-property', 'opacity'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
4050
+ if (base === 'transition-shadow') return [['transition-property', 'box-shadow'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
4051
+ if (base === 'transition-none') return [['transition-property', 'none']]
3768
4052
  if (base === 'transition-transform') return [['transition-property', 'transform'], ['transition-duration', 'var(--duration-normal)'], ['transition-timing-function', 'var(--ease-standard)']]
3769
4053
  match = base.match(/^transition-\[(.+)\]$/)
3770
4054
  if (match) return [['transition-property', arbitraryValue(match[1])], ['transition-duration', 'var(--duration-normal)']]
@@ -3777,6 +4061,9 @@ function effectUtility(base) {
3777
4061
  if (base === 'ease-in-out') return [['transition-timing-function', 'cubic-bezier(0.4, 0, 0.2, 1)']]
3778
4062
  if (base === 'animate-pulse') return [['animation', 'sf-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite']]
3779
4063
  if (base === 'animate-spin') return [['animation', 'sf-spin 1s linear infinite']]
4064
+ if (base === 'animate-ping') return [['animation', 'sf-ping 1s cubic-bezier(0, 0, 0.2, 1) infinite']]
4065
+ if (base === 'animate-bounce') return [['animation', 'sf-bounce 1s infinite']]
4066
+ if (base === 'animate-none') return [['animation', 'none']]
3780
4067
  if (base === 'animate-in') return [['animation', 'sf-enter var(--duration-normal) var(--ease-standard) both']]
3781
4068
  if (base === 'animate-out') return [['animation', 'sf-exit var(--duration-fast) var(--ease-standard) both']]
3782
4069
  return null
@@ -3787,7 +4074,7 @@ function transformUtility(base) {
3787
4074
  let match = base.match(/^(-?)translate-x-(.+)$/)
3788
4075
  if (match) declarations.push(['--sf-translate-x', `${match[1] ? '-' : ''}${spacingUnit(match[2]) ?? sizeKeyword(match[2])}`])
3789
4076
  match = base.match(/^(-?)translate-y-(.+)$/)
3790
- if (match) declarations.push(['--sf-translate-y', `${match[1] ? '-' : ''}${spacingUnit(match[2]) ?? sizeKeyword(match[2])}`])
4077
+ if (match) declarations.push(['--sf-translate-y', `${match[1] ? '-' : ''}${spacingUnit(match[2]) ?? sizeKeyword(match[2], 'y')}`])
3791
4078
  match = base.match(/^scale-\[(.+)\]$/)
3792
4079
  if (match) declarations.push(['--sf-scale-x', arbitraryValue(match[1])], ['--sf-scale-y', arbitraryValue(match[1])])
3793
4080
  match = base.match(/^scale-(\d+)$/)
@@ -3812,7 +4099,9 @@ function cssRuleFor(token) {
3812
4099
  const selectorResult = selectorFor(token, variants)
3813
4100
  if (!selectorResult) return null
3814
4101
 
3815
- const { selector, wrappers, needsPseudoContent } = selectorResult
4102
+ const { wrappers, needsPseudoContent } = selectorResult
4103
+ // space-* and divide-* style the gaps between children, not the element itself.
4104
+ const selector = /^-?(space-[xy]-|divide-)/.test(base) ? `${selectorResult.selector} > * + *` : selectorResult.selector
3816
4105
  const allDeclarations = needsPseudoContent ? [['content', '""'], ...declarations] : declarations
3817
4106
  let rule = `${selector}{${allDeclarations.map(([property, value]) => `${property}:${value}`).join(';')}}`
3818
4107
 
@@ -4480,16 +4769,6 @@ function buildLayoutCss() {
4480
4769
  }
4481
4770
  }
4482
4771
 
4483
- :where(.space-y-1, .space-y-2, .space-y-3, .space-y-4, .space-y-5, .space-y-6, .space-y-8, .space-y-10, .space-y-12) > * + * {
4484
- margin-block-start: var(--sf-space-y);
4485
- }
4486
-
4487
- :where(.space-x-1, .space-x-2, .space-x-3, .space-x-4, .space-x-5, .space-x-6, .space-x-8) > * + * {
4488
- margin-inline-start: var(--sf-space-x);
4489
- }
4490
-
4491
- .divide-y > * + * { border-block-start-width: var(--sf-divide-y); }
4492
- .divide-x > * + * { border-inline-start-width: var(--sf-divide-x); }
4493
4772
  }`
4494
4773
  }
4495
4774
 
@@ -4795,6 +5074,8 @@ function buildComponentCss() {
4795
5074
 
4796
5075
  .sf-data-table :where(th button, .sf-table-sort) {
4797
5076
  align-items: center;
5077
+ background: transparent;
5078
+ border: 0;
4798
5079
  color: inherit;
4799
5080
  display: inline-flex;
4800
5081
  font: inherit;
@@ -4803,14 +5084,14 @@ function buildComponentCss() {
4803
5084
  padding: 0;
4804
5085
  }
4805
5086
 
4806
- .sf-data-table :where(th button, .sf-table-sort)[aria-sort]::after {
5087
+ .sf-data-table th[aria-sort] :where(button, .sf-table-sort)::after {
4807
5088
  color: var(--color-muted-foreground);
4808
5089
  content: "\\2195";
4809
5090
  font-size: var(--step--2);
4810
5091
  }
4811
5092
 
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"; }
5093
+ .sf-data-table th[aria-sort="ascending"] :where(button, .sf-table-sort)::after { content: "\\2191"; }
5094
+ .sf-data-table th[aria-sort="descending"] :where(button, .sf-table-sort)::after { content: "\\2193"; }
4814
5095
 
4815
5096
  .sf-data-table tr[data-clickable="true"] {
4816
5097
  cursor: pointer;
@@ -4885,7 +5166,7 @@ function buildComponentCss() {
4885
5166
  }
4886
5167
 
4887
5168
  .sf-status::before {
4888
- background: currentColor;
5169
+ background: var(--status-dot, currentColor);
4889
5170
  block-size: .42em;
4890
5171
  border-radius: var(--radius-full);
4891
5172
  content: "";
@@ -4895,25 +5176,29 @@ function buildComponentCss() {
4895
5176
  .sf-status[data-tone="success"] {
4896
5177
  --status-bg: var(--sf-colour-success-soft, oklch(62% 0.13 150 / 0.12));
4897
5178
  --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));
5179
+ --status-color: var(--color-foreground);
5180
+ --status-dot: var(--sf-colour-success, oklch(62% 0.13 150));
4899
5181
  }
4900
5182
 
4901
5183
  .sf-status[data-tone="warning"] {
4902
5184
  --status-bg: var(--sf-colour-warning-soft, oklch(78% 0.15 82 / 0.16));
4903
5185
  --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));
5186
+ --status-color: var(--color-foreground);
5187
+ --status-dot: color-mix(in oklch, var(--sf-colour-warning, oklch(78% 0.15 82)) 60%, var(--color-foreground));
4905
5188
  }
4906
5189
 
4907
5190
  .sf-status[data-tone="danger"] {
4908
5191
  --status-bg: var(--sf-colour-danger-soft, oklch(56% 0.2 28 / 0.12));
4909
5192
  --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));
5193
+ --status-color: var(--color-foreground);
5194
+ --status-dot: var(--sf-colour-danger, var(--color-destructive));
4911
5195
  }
4912
5196
 
4913
5197
  .sf-status[data-tone="info"] {
4914
5198
  --status-bg: var(--sf-colour-info-soft, oklch(70% 0.12 205 / 0.14));
4915
5199
  --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));
5200
+ --status-color: var(--color-foreground);
5201
+ --status-dot: color-mix(in oklch, var(--sf-colour-info, oklch(70% 0.12 205)) 68%, var(--color-foreground));
4917
5202
  }
4918
5203
 
4919
5204
  .sf-data-list {
@@ -4961,9 +5246,16 @@ function buildComponentCss() {
4961
5246
  background: var(--color-surface-alt);
4962
5247
  }
4963
5248
 
5249
+ .sf-form {
5250
+ display: grid;
5251
+ gap: var(--form-gap, var(--space-s));
5252
+ grid-template-columns: minmax(0, 1fr);
5253
+ }
5254
+
4964
5255
  .sf-field {
4965
5256
  display: grid;
4966
5257
  gap: var(--space-2xs);
5258
+ grid-template-columns: minmax(0, 1fr);
4967
5259
  }
4968
5260
 
4969
5261
  .sf-form[data-density="compact"],
@@ -5026,6 +5318,130 @@ function buildComponentCss() {
5026
5318
  outline: 0.125rem solid color-mix(in oklch, var(--color-ring) 24%, transparent);
5027
5319
  outline-offset: 0;
5028
5320
  }
5321
+
5322
+ .sf-spinner {
5323
+ animation: sf-spinner-rotate .75s linear infinite;
5324
+ block-size: var(--spinner-size, 1em);
5325
+ border: .125em solid color-mix(in oklch, currentColor 28%, transparent);
5326
+ border-block-start-color: currentColor;
5327
+ border-radius: var(--radius-full);
5328
+ display: inline-block;
5329
+ flex: 0 0 auto;
5330
+ inline-size: var(--spinner-size, 1em);
5331
+ }
5332
+
5333
+ @keyframes sf-spinner-rotate { to { transform: rotate(1turn); } }
5334
+
5335
+ .sf-input-group {
5336
+ align-items: stretch;
5337
+ background: var(--color-card);
5338
+ border: 1px solid var(--color-border);
5339
+ border-radius: var(--input-radius, var(--radius-md));
5340
+ display: flex;
5341
+ inline-size: 100%;
5342
+ min-block-size: var(--input-block-size, 2.75rem);
5343
+ overflow: clip;
5344
+ }
5345
+
5346
+ .sf-input-group:focus-within {
5347
+ border-color: var(--color-ring);
5348
+ outline: .125rem solid color-mix(in oklch, var(--color-ring) 24%, transparent);
5349
+ }
5350
+
5351
+ .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; }
5352
+ .sf-input-group__leading,
5353
+ .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; }
5354
+ .sf-input-group__leading { border-inline-end: 1px solid var(--color-border); }
5355
+ .sf-input-group__trailing,
5356
+ .sf-input-group__action { border-inline-start: 1px solid var(--color-border); }
5357
+ .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); }
5358
+ .sf-input-group[data-invalid="true"],
5359
+ .sf-field[data-invalid="true"] .sf-input-group { border-color: var(--color-destructive, var(--color-danger)); }
5360
+ .sf-input-group:has(:disabled) { background: var(--color-surface-alt); opacity: .72; }
5361
+
5362
+ .sf-file-input {
5363
+ background: var(--color-card);
5364
+ border: 1px solid var(--color-border);
5365
+ border-radius: var(--input-radius, var(--radius-md));
5366
+ color: var(--color-muted-foreground);
5367
+ inline-size: 100%;
5368
+ min-block-size: var(--input-block-size, 2.75rem);
5369
+ padding: var(--space-3xs);
5370
+ }
5371
+
5372
+ .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); }
5373
+ .sf-range { accent-color: var(--color-primary); inline-size: 100%; min-block-size: var(--input-block-size, 2.75rem); }
5374
+ .sf-range:focus-visible,
5375
+ .sf-file-input:focus-visible { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5376
+ .sf-file-input[aria-invalid="true"],
5377
+ .sf-field[data-invalid="true"] .sf-file-input { border-color: var(--color-destructive, var(--color-danger)); }
5378
+ :where(.sf-file-input, .sf-range):disabled { opacity: .55; }
5379
+
5380
+ .sf-switch { align-items: center; display: inline-grid; gap: var(--space-xs); grid-template-columns: auto 1fr; position: relative; }
5381
+ .sf-switch > input[type="checkbox"],
5382
+ .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; }
5383
+ .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; }
5384
+ .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); }
5385
+ .sf-switch > input:checked + .sf-switch__control { background: var(--color-primary); border-color: var(--color-primary); }
5386
+ .sf-switch > input:checked + .sf-switch__control::before { background: var(--color-primary-foreground); transform: translateX(1.1875rem); }
5387
+ .sf-switch > input:focus-visible + .sf-switch__control { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5388
+ .sf-switch__label { color: var(--color-foreground); font-weight: 700; }
5389
+ .sf-switch:has(input:disabled) { cursor: not-allowed; opacity: .55; }
5390
+
5391
+ .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); }
5392
+ .sf-segmented-control__option { cursor: pointer; position: relative; }
5393
+ .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); }
5394
+ .sf-segmented-control__option > input:checked + .sf-segmented-control__label { background: var(--color-card); border-color: var(--color-border); color: var(--color-foreground); }
5395
+ .sf-segmented-control__option > input:focus + .sf-segmented-control__label { outline: .125rem solid var(--color-ring); outline-offset: .125rem; }
5396
+ .sf-segmented-control__option:has(input:disabled) { cursor: not-allowed; opacity: .55; }
5397
+
5398
+ .sf-combobox { position: relative; }
5399
+ .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; }
5400
+ .sf-combobox__option { border-radius: var(--radius-sm); cursor: pointer; padding: var(--space-2xs) var(--space-xs); }
5401
+ .sf-combobox__option:hover,
5402
+ .sf-combobox__option[aria-selected="true"],
5403
+ .sf-combobox__option[data-active="true"] { background: var(--color-surface-alt); }
5404
+ .sf-combobox__empty { color: var(--color-muted-foreground); padding: var(--space-xs); }
5405
+
5406
+ .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); }
5407
+ .sf-bulk-actions__count { font-size: var(--step--1); font-weight: 800; }
5408
+ .sf-bulk-actions__controls,
5409
+ .sf-pagination__controls,
5410
+ .sf-pagination__pages,
5411
+ .sf-pagination__jump { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
5412
+
5413
+ .sf-pagination { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-2xs); list-style: none; padding-inline-start: 0; }
5414
+ .sf-pagination[data-layout="complete"] { justify-content: space-between; row-gap: var(--space-xs); }
5415
+ .sf-pagination__summary,
5416
+ .sf-pagination__jump { color: var(--color-muted-foreground); font-size: var(--step--1); }
5417
+ .sf-pagination__jump .sf-input { inline-size: 5rem; }
5418
+
5419
+ .sf-stepper { counter-reset: sf-step; display: grid; gap: var(--space-xs); list-style: none; padding-inline-start: 0; }
5420
+ .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; }
5421
+ .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; }
5422
+ .sf-stepper__marker:empty::before { content: counter(sf-step); }
5423
+ .sf-stepper__step[aria-current="step"] { color: var(--color-foreground); }
5424
+ .sf-stepper__step[aria-current="step"] .sf-stepper__marker { background: var(--color-primary); border-color: var(--color-primary); color: var(--color-primary-foreground); }
5425
+ .sf-stepper__label { font-weight: 700; }
5426
+ .sf-wizard-actions { align-items: center; display: flex; flex-wrap: wrap; gap: var(--space-xs); justify-content: space-between; }
5427
+
5428
+ @media (prefers-reduced-motion: reduce) {
5429
+ .sf-spinner { animation: none; border-color: currentColor; border-inline-end-color: transparent; }
5430
+ .sf-switch__control::before { transition: none; }
5431
+ }
5432
+
5433
+ @media (forced-colors: active) {
5434
+ :where(.sf-input-group, .sf-file-input, .sf-combobox__listbox, .sf-bulk-actions) { border-color: ButtonText; forced-color-adjust: auto; }
5435
+ :where(.sf-switch__control, .sf-segmented-control__label, .sf-stepper__marker) { background: Canvas; border-color: ButtonText; color: ButtonText; forced-color-adjust: none; }
5436
+ .sf-switch > input:focus-visible + .sf-switch__control,
5437
+ .sf-segmented-control__option > input:focus + .sf-segmented-control__label { outline: .125rem solid Highlight; outline-offset: .125rem; }
5438
+ .sf-switch > input:checked + .sf-switch__control,
5439
+ .sf-segmented-control__option > input:checked + .sf-segmented-control__label,
5440
+ .sf-stepper__step[aria-current="step"] .sf-stepper__marker { background: Highlight; border-color: Highlight; color: HighlightText; }
5441
+ .sf-switch__control::before { background: ButtonText; }
5442
+ .sf-switch > input:checked + .sf-switch__control::before { background: HighlightText; }
5443
+ .sf-stepper__step[data-complete="true"] .sf-stepper__marker { background: Canvas; border-color: LinkText; color: LinkText; }
5444
+ }
5029
5445
  }`
5030
5446
  }
5031
5447
 
@@ -5180,6 +5596,8 @@ function buildUsedKeyframesCss(bases) {
5180
5596
 
5181
5597
  if (bases.has('animate-pulse')) keyframes.push('@keyframes sf-pulse { 50% { opacity: .5; } }')
5182
5598
  if (bases.has('animate-spin')) keyframes.push('@keyframes sf-spin { to { transform: rotate(360deg); } }')
5599
+ if (bases.has('animate-ping')) keyframes.push('@keyframes sf-ping { 75%, 100% { transform: scale(2); opacity: 0; } }')
5600
+ if (bases.has('animate-bounce')) keyframes.push('@keyframes sf-bounce { 0%, 100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } 50% { transform: none; animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } }')
5183
5601
  if (bases.has('animate-in')) keyframes.push('@keyframes sf-enter { from { opacity: 0; transform: translate3d(0, .5rem, 0) scale(.98); } to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } }')
5184
5602
  if (bases.has('animate-out')) keyframes.push('@keyframes sf-exit { from { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } to { opacity: 0; transform: translate3d(0, .25rem, 0) scale(.98); } }')
5185
5603
 
@@ -5221,11 +5639,25 @@ function utilitySuggestionClasses() {
5221
5639
 
5222
5640
  function nearestClass(token) {
5223
5641
  const { base } = splitVariants(token)
5224
- const candidates = knownPublicClasses()
5225
- const scored = candidates
5642
+ const known = knownPublicClasses()
5643
+ // Flow's own version of a common class, e.g. `prose` -> `sf-prose`.
5644
+ if (known.includes(`sf-${base}`)) return `sf-${base}`
5645
+ const scored = [...siblingUtilities(base), ...known]
5226
5646
  .map((candidate) => ({ candidate, score: levenshtein(base, candidate) }))
5227
5647
  .sort((a, b) => a.score - b.score)
5228
- return scored[0]?.score <= Math.max(4, Math.ceil(base.length / 3)) ? scored[0].candidate : null
5648
+ // Only near typos: a looser match often names a class that does something else.
5649
+ return scored[0]?.score <= Math.max(1, Math.floor(base.length / 5)) ? scored[0].candidate : null
5650
+ }
5651
+
5652
+ // The same utility with other keyword or spacing values, e.g. `h-scren` -> `h-screen`.
5653
+ function siblingUtilities(base) {
5654
+ const cut = base.lastIndexOf('-')
5655
+ if (cut <= 0) return []
5656
+ const prefix = base.slice(0, cut)
5657
+ const values = [...Object.keys(sizeKeywords), 'none', 'first', 'last', 'center', 'start', 'end', '0', 'px', '1', '2', '3', '4', '6', '8', '10', '12', '16', '24']
5658
+ return values
5659
+ .map((value) => `${prefix}-${value}`)
5660
+ .filter((candidate) => candidate !== base && declarationsFor(candidate))
5229
5661
  }
5230
5662
 
5231
5663
  function lintSourceFiles() {