@dryui/ui 0.1.12 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -74,6 +74,7 @@ export { DatePicker } from './date-picker/index.js';
74
74
  export { Calendar } from './calendar/index.js';
75
75
  export { DateRangePicker } from './date-range-picker/index.js';
76
76
  export { Listbox } from './listbox/index.js';
77
+ export { LogoMark } from './logo-mark/index.js';
77
78
  export { PinInput } from './pin-input/index.js';
78
79
  export { RangeCalendar } from './range-calendar/index.js';
79
80
  export { Rating } from './rating/index.js';
@@ -0,0 +1,10 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ export interface LogoMarkProps extends HTMLAttributes<HTMLSpanElement> {
3
+ src?: string;
4
+ alt?: string;
5
+ fallback?: string;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ shape?: 'square' | 'rounded' | 'circle';
8
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
9
+ }
10
+ export { default as LogoMark } from './logo-mark.svelte';
@@ -0,0 +1 @@
1
+ export { default as LogoMark } from './logo-mark.svelte';
@@ -0,0 +1,154 @@
1
+ <script lang="ts">
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+
4
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
5
+ src?: string;
6
+ alt?: string;
7
+ fallback?: string;
8
+ size?: 'sm' | 'md' | 'lg';
9
+ shape?: 'square' | 'rounded' | 'circle';
10
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
11
+ }
12
+
13
+ let {
14
+ src,
15
+ alt = '',
16
+ fallback,
17
+ size = 'md',
18
+ shape = 'rounded',
19
+ color = 'neutral',
20
+ class: className,
21
+ ...rest
22
+ }: Props = $props();
23
+
24
+ let failedSrc = $state<string | null>(null);
25
+ const showImage = $derived(Boolean(src) && src !== failedSrc);
26
+
27
+ function handleError() {
28
+ failedSrc = src ?? null;
29
+ }
30
+
31
+ function getFallbackText(): string {
32
+ const source = (fallback ?? alt).trim();
33
+ if (!source) return '';
34
+
35
+ const compact = source.replace(/[^\p{L}\p{N}]+/gu, '');
36
+ const characters = Array.from(compact || source);
37
+ return characters.slice(0, 2).join('').toUpperCase();
38
+ }
39
+ </script>
40
+
41
+ <span
42
+ role="img"
43
+ aria-label={alt}
44
+ data-logo-mark
45
+ data-size={size}
46
+ data-shape={shape}
47
+ data-color={color}
48
+ class={className}
49
+ {...rest}
50
+ >
51
+ {#if showImage}
52
+ <img {src} {alt} onerror={handleError} />
53
+ {:else}
54
+ <span aria-hidden="true">{getFallbackText()}</span>
55
+ {/if}
56
+ </span>
57
+
58
+ <style>
59
+ [data-logo-mark] {
60
+ --dry-logo-mark-size: 32px;
61
+ --dry-logo-mark-radius: var(--dry-radius-md);
62
+ --dry-logo-mark-bg: var(--dry-color-fill);
63
+ --dry-logo-mark-color: var(--dry-color-text-weak);
64
+
65
+ display: inline-grid;
66
+ place-items: center;
67
+ aspect-ratio: 1;
68
+ height: var(--dry-logo-mark-size);
69
+ border-radius: var(--dry-logo-mark-radius);
70
+ background: var(--dry-logo-mark-bg);
71
+ color: var(--dry-logo-mark-color);
72
+ font-family: var(--dry-font-sans);
73
+ font-size: var(--dry-logo-mark-font-size, var(--dry-type-small-size, var(--dry-text-sm-size)));
74
+ font-weight: 600;
75
+ line-height: 1;
76
+ overflow: hidden;
77
+ user-select: none;
78
+ }
79
+
80
+ [data-logo-mark] img {
81
+ width: 100%;
82
+ height: 100%;
83
+ object-fit: cover;
84
+ border-radius: inherit;
85
+ }
86
+
87
+ [data-logo-mark] > span {
88
+ display: grid;
89
+ place-items: center;
90
+ }
91
+
92
+ /* -- Shapes ------------------------------------------------------------- */
93
+
94
+ [data-shape='square'] {
95
+ --dry-logo-mark-radius: var(--dry-radius-sm);
96
+ }
97
+
98
+ [data-shape='rounded'] {
99
+ --dry-logo-mark-radius: var(--dry-radius-md);
100
+ }
101
+
102
+ [data-shape='circle'] {
103
+ --dry-logo-mark-radius: var(--dry-radius-full);
104
+ }
105
+
106
+ /* -- Sizes -------------------------------------------------------------- */
107
+
108
+ [data-size='sm'] {
109
+ --dry-logo-mark-size: 24px;
110
+ --dry-logo-mark-font-size: var(--dry-type-tiny-size, var(--dry-text-xs-size));
111
+ }
112
+
113
+ [data-size='md'] {
114
+ --dry-logo-mark-size: 32px;
115
+ --dry-logo-mark-font-size: var(--dry-type-small-size, var(--dry-text-sm-size));
116
+ }
117
+
118
+ [data-size='lg'] {
119
+ --dry-logo-mark-size: 40px;
120
+ --dry-logo-mark-font-size: var(--dry-type-small-size, var(--dry-text-base-size));
121
+ }
122
+
123
+ /* -- Colors ------------------------------------------------------------- */
124
+
125
+ [data-color='neutral'] {
126
+ --dry-logo-mark-bg: var(--dry-color-fill);
127
+ --dry-logo-mark-color: var(--dry-color-text-weak);
128
+ }
129
+
130
+ [data-color='brand'] {
131
+ --dry-logo-mark-bg: var(--dry-color-fill-brand-weak);
132
+ --dry-logo-mark-color: var(--dry-color-text-brand);
133
+ }
134
+
135
+ [data-color='error'] {
136
+ --dry-logo-mark-bg: var(--dry-color-fill-error-weak);
137
+ --dry-logo-mark-color: var(--dry-color-text-error);
138
+ }
139
+
140
+ [data-color='warning'] {
141
+ --dry-logo-mark-bg: var(--dry-color-fill-warning-weak);
142
+ --dry-logo-mark-color: var(--dry-color-text-warning);
143
+ }
144
+
145
+ [data-color='success'] {
146
+ --dry-logo-mark-bg: var(--dry-color-fill-success-weak);
147
+ --dry-logo-mark-color: var(--dry-color-text-success);
148
+ }
149
+
150
+ [data-color='info'] {
151
+ --dry-logo-mark-bg: var(--dry-color-fill-info-weak);
152
+ --dry-logo-mark-color: var(--dry-color-text-info);
153
+ }
154
+ </style>
@@ -0,0 +1,12 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ interface Props extends HTMLAttributes<HTMLSpanElement> {
3
+ src?: string;
4
+ alt?: string;
5
+ fallback?: string;
6
+ size?: 'sm' | 'md' | 'lg';
7
+ shape?: 'square' | 'rounded' | 'circle';
8
+ color?: 'brand' | 'neutral' | 'error' | 'warning' | 'success' | 'info';
9
+ }
10
+ declare const LogoMark: import("svelte").Component<Props, {}, "">;
11
+ type LogoMark = ReturnType<typeof LogoMark>;
12
+ export default LogoMark;
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { onDestroy } from 'svelte';
3
+ import { onDestroy, untrack } from 'svelte';
4
4
  import { getMapCtx, setMarkerCtx } from './context.svelte.js';
5
5
  import type { LngLat, MapMarkerInstance, MarkerOptions } from '@dryui/primitives';
6
6
 
@@ -30,9 +30,10 @@
30
30
  $effect(() => {
31
31
  if (!ctx.loaded || !ctx.map || !ctx.lib) return;
32
32
 
33
- if (markerInstance) {
34
- markerInstance.remove();
35
- markerInstance = null;
33
+ // Remove previous marker without tracking markerInstance as a dependency
34
+ const prev = untrack(() => markerInstance);
35
+ if (prev) {
36
+ prev.remove();
36
37
  }
37
38
 
38
39
  const opts: MarkerOptions = {};
@@ -1,6 +1,6 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { onDestroy } from 'svelte';
3
+ import { onDestroy, untrack } from 'svelte';
4
4
  import { getMapCtx, getMarkerCtx } from './context.svelte.js';
5
5
  import type { MapPopupInstance, PopupOptions } from '@dryui/primitives';
6
6
 
@@ -29,9 +29,10 @@
29
29
  $effect(() => {
30
30
  if (!mapCtx.loaded || !mapCtx.map || !mapCtx.lib || !markerCtx.marker || !contentEl) return;
31
31
 
32
- if (popupInstance) {
33
- popupInstance.remove();
34
- popupInstance = null;
32
+ // Remove previous popup without tracking popupInstance as a dependency
33
+ const prev = untrack(() => popupInstance);
34
+ if (prev) {
35
+ prev.remove();
35
36
  }
36
37
 
37
38
  try {
@@ -47,11 +47,10 @@
47
47
  const isDisabled = $derived(disabled || formCtx?.disabled || false);
48
48
  const hasError = $derived(formCtx?.hasError || false);
49
49
 
50
- let inputEl: HTMLInputElement | undefined = $state();
50
+ let inputEl: HTMLInputElement | undefined;
51
51
  let isFocused = $state(false);
52
52
  let mirrorSelectionStart = $state<number | null>(null);
53
53
  let mirrorSelectionEnd = $state<number | null>(null);
54
- let completeFired = $state(false);
55
54
 
56
55
  const validationRegex = $derived(pattern ?? (type === 'numeric' ? /^\d+$/ : /^[a-zA-Z0-9]+$/));
57
56
 
@@ -98,24 +97,28 @@
98
97
  mirrorSelectionEnd = inputEl.selectionEnd;
99
98
  }
100
99
 
101
- $effect(() => {
102
- const handler = () => {
103
- if (document.activeElement === inputEl) {
104
- syncSelection();
100
+ function captureInput(node: HTMLInputElement) {
101
+ inputEl = node;
102
+ return () => {
103
+ if (inputEl === node) {
104
+ inputEl = undefined;
105
105
  }
106
106
  };
107
- document.addEventListener('selectionchange', handler);
108
- return () => document.removeEventListener('selectionchange', handler);
109
- });
107
+ }
110
108
 
111
- $effect(() => {
112
- if (value.length < length) {
113
- completeFired = false;
109
+ function maybeFireComplete(previousValue: string, nextValue: string) {
110
+ if (nextValue.length !== length) return;
111
+ if (previousValue.length === length && previousValue === nextValue) return;
112
+
113
+ oncomplete?.(nextValue);
114
+ if (blurOnComplete) {
115
+ inputEl?.blur();
114
116
  }
115
- });
117
+ }
116
118
 
117
119
  function handleInput(e: Event) {
118
120
  const input = e.target as HTMLInputElement;
121
+ const previousValue = value;
119
122
  let newValue = input.value;
120
123
 
121
124
  newValue = newValue
@@ -126,18 +129,12 @@
126
129
  newValue = newValue.slice(0, length);
127
130
  value = newValue;
128
131
  syncSelection();
129
-
130
- if (newValue.length === length && !completeFired) {
131
- completeFired = true;
132
- oncomplete?.(newValue);
133
- if (blurOnComplete) {
134
- inputEl?.blur();
135
- }
136
- }
132
+ maybeFireComplete(previousValue, newValue);
137
133
  }
138
134
 
139
135
  function handlePaste(e: ClipboardEvent) {
140
136
  e.preventDefault();
137
+ const previousValue = value;
141
138
  let pasted = e.clipboardData?.getData('text') ?? '';
142
139
  if (pasteTransformer) {
143
140
  pasted = pasteTransformer(pasted);
@@ -166,13 +163,7 @@
166
163
  syncSelection();
167
164
  });
168
165
 
169
- if (newValue.length === length && !completeFired) {
170
- completeFired = true;
171
- oncomplete?.(newValue);
172
- if (blurOnComplete) {
173
- inputEl?.blur();
174
- }
175
- }
166
+ maybeFireComplete(previousValue, newValue);
176
167
  }
177
168
 
178
169
  function handleFocus() {
@@ -212,13 +203,10 @@
212
203
  const sizeAttr = $derived(`data-pin-input-${size}`);
213
204
  </script>
214
205
 
215
- <!-- svelte-ignore a11y_role_supports_aria_props -->
216
206
  <div
217
207
  role="group"
218
208
  aria-label="PIN input"
219
209
  aria-describedby={formCtx?.describedBy}
220
- aria-invalid={hasError || undefined}
221
- aria-errormessage={formCtx?.errorMessageId}
222
210
  data-pin-input-root
223
211
  data-disabled={isDisabled || undefined}
224
212
  data-error={hasError || undefined}
@@ -230,7 +218,7 @@
230
218
  {...rest}
231
219
  >
232
220
  <input
233
- bind:this={inputEl}
221
+ {@attach captureInput}
234
222
  type="text"
235
223
  inputmode={type === 'numeric' ? 'numeric' : 'text'}
236
224
  autocomplete="one-time-code"
@@ -238,6 +226,9 @@
238
226
  {value}
239
227
  id={formCtx?.id}
240
228
  aria-label="PIN input"
229
+ aria-describedby={formCtx?.describedBy}
230
+ aria-invalid={hasError || undefined}
231
+ aria-errormessage={formCtx?.errorMessageId}
241
232
  aria-required={formCtx?.required || undefined}
242
233
  disabled={isDisabled}
243
234
  spellcheck={false}
@@ -247,6 +238,8 @@
247
238
  onfocus={handleFocus}
248
239
  onblur={handleBlur}
249
240
  onkeydown={handleKeydown}
241
+ onkeyup={syncSelection}
242
+ onselect={syncSelection}
250
243
  />
251
244
 
252
245
  {#if userChildren}
@@ -2,6 +2,12 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { generateFormId } from '@dryui/primitives';
4
4
  import { setSelectCtx } from './context.svelte.js';
5
+ import SelectTrigger from './select-trigger.svelte';
6
+ import SelectValue from './select-value.svelte';
7
+ import SelectContent from './select-content.svelte';
8
+ import SelectItem from './select-item.svelte';
9
+
10
+ type SelectOption = { value: string; label: string };
5
11
 
6
12
  interface Props {
7
13
  open?: boolean;
@@ -9,7 +15,9 @@
9
15
  disabled?: boolean;
10
16
  name?: string;
11
17
  class?: string;
12
- children: Snippet;
18
+ options?: Array<string | SelectOption>;
19
+ placeholder?: string;
20
+ children?: Snippet;
13
21
  }
14
22
 
15
23
  let {
@@ -18,9 +26,15 @@
18
26
  disabled = false,
19
27
  name,
20
28
  class: className,
29
+ options,
30
+ placeholder,
21
31
  children
22
32
  }: Props = $props();
23
33
 
34
+ const normalizedOptions = $derived(
35
+ options?.map((opt) => (typeof opt === 'string' ? { value: opt, label: opt } : opt))
36
+ );
37
+
24
38
  const triggerId = generateFormId('select-trigger');
25
39
  const contentId = generateFormId('select-content');
26
40
 
@@ -59,7 +73,18 @@
59
73
  </script>
60
74
 
61
75
  <div data-select-wrapper class={className}>
62
- {@render children()}
76
+ {#if normalizedOptions && !children}
77
+ <SelectTrigger>
78
+ <SelectValue {placeholder} />
79
+ </SelectTrigger>
80
+ <SelectContent>
81
+ {#each normalizedOptions as opt (opt.value)}
82
+ <SelectItem value={opt.value}>{opt.label}</SelectItem>
83
+ {/each}
84
+ </SelectContent>
85
+ {:else if children}
86
+ {@render children()}
87
+ {/if}
63
88
 
64
89
  {#if name}
65
90
  <input type="hidden" {name} {value} disabled={disabled || undefined} />
@@ -5,7 +5,9 @@ interface Props {
5
5
  disabled?: boolean;
6
6
  name?: string;
7
7
  class?: string;
8
- children: Snippet;
8
+ options?: Array<string | { value: string; label: string }>;
9
+ placeholder?: string;
10
+ children?: Snippet;
9
11
  }
10
12
  declare const SelectRoot: import('svelte').Component<Props, {}, 'value' | 'open'>;
11
13
  type SelectRoot = ReturnType<typeof SelectRoot>;
@@ -1,8 +1,12 @@
1
1
  <script lang="ts">
2
- import { untrack } from 'svelte';
3
2
  import { getFormControlCtx } from '@dryui/primitives';
4
3
  import { Select } from '../select/index.js';
5
4
 
5
+ type TimeParts = {
6
+ hour: string;
7
+ minute: string;
8
+ };
9
+
6
10
  interface Props {
7
11
  value?: string;
8
12
  disabled?: boolean;
@@ -23,43 +27,46 @@
23
27
 
24
28
  const ctx = getFormControlCtx();
25
29
  const isDisabled = $derived(disabled || ctx?.disabled || false);
30
+ const hasError = $derived(ctx?.hasError || false);
31
+ const describedBy = $derived(ctx?.describedBy);
32
+ const errorMessageId = $derived(ctx?.hasError ? ctx?.errorMessageId : undefined);
26
33
 
27
- let hourStr = $state('');
28
- let minuteStr = $state('');
29
- let updating = false;
30
-
31
- // Parse incoming value into hour/minute (only reacts to value changes)
32
- $effect(() => {
33
- if (updating) return;
34
- const parts = value.split(':');
35
- const h = parts[0] ?? '';
36
- const m = parts[1] ?? '';
37
- untrack(() => {
38
- if (h !== hourStr) hourStr = h;
39
- if (m !== minuteStr) minuteStr = m;
40
- });
41
- });
34
+ function parseTime(nextValue: string): TimeParts {
35
+ const [hour = '', minute = ''] = nextValue.split(':');
36
+ return { hour, minute };
37
+ }
38
+
39
+ let draftHour = $state('');
40
+ let draftMinute = $state('');
42
41
 
43
- // Reconstruct value when selects change (only reacts to hourStr/minuteStr)
44
- $effect(() => {
45
- const h = hourStr;
46
- const m = minuteStr;
47
- if (h && m) {
48
- const newVal = `${h}:${m}`;
49
- untrack(() => {
50
- if (newVal !== value) {
51
- updating = true;
52
- value = newVal;
53
- updating = false;
54
- }
55
- });
42
+ const parsedValue = $derived.by(() => parseTime(value));
43
+ const hourStr = $derived(value ? parsedValue.hour : draftHour);
44
+ const minuteStr = $derived(value ? parsedValue.minute : draftMinute);
45
+
46
+ function setTime(nextHour: string, nextMinute: string) {
47
+ draftHour = nextHour;
48
+ draftMinute = nextMinute;
49
+
50
+ if (nextHour && nextMinute) {
51
+ value = `${nextHour}:${nextMinute}`;
52
+ draftHour = '';
53
+ draftMinute = '';
54
+ return;
56
55
  }
57
- });
58
56
 
59
- // Generate hour options 00-23
57
+ value = '';
58
+ }
59
+
60
+ function setHour(nextHour: string) {
61
+ setTime(nextHour, minuteStr);
62
+ }
63
+
64
+ function setMinute(nextMinute: string) {
65
+ setTime(hourStr, nextMinute);
66
+ }
67
+
60
68
  const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
61
69
 
62
- // Generate minute options based on step
63
70
  const minutes = $derived.by(() => {
64
71
  const stepMinutes = step && step >= 60 ? Math.floor(step / 60) : 1;
65
72
  const count = Math.floor(60 / stepMinutes);
@@ -75,12 +82,16 @@
75
82
  data-time-input-wrapper
76
83
  data-disabled={isDisabled || undefined}
77
84
  id={ctx?.id}
78
- aria-describedby={[ctx?.describedBy, ctx?.hasError ? ctx?.errorMessageId : undefined].filter(Boolean).join(' ') || undefined}
79
- aria-invalid={ctx?.hasError || undefined}
80
85
  class={className}
81
86
  >
82
- <Select.Root bind:value={hourStr} disabled={isDisabled}>
83
- <Select.Trigger {size} aria-label="Hour">
87
+ <Select.Root bind:value={() => hourStr, setHour} disabled={isDisabled}>
88
+ <Select.Trigger
89
+ {size}
90
+ aria-label="Hour"
91
+ aria-describedby={describedBy}
92
+ aria-invalid={hasError || undefined}
93
+ aria-errormessage={errorMessageId}
94
+ >
84
95
  <span data-time-display data-placeholder={!hourStr ? '' : undefined}>
85
96
  {hourStr || 'HH'}
86
97
  </span>
@@ -94,8 +105,14 @@
94
105
 
95
106
  <span data-time-separator>:</span>
96
107
 
97
- <Select.Root bind:value={minuteStr} disabled={isDisabled}>
98
- <Select.Trigger {size} aria-label="Minute">
108
+ <Select.Root bind:value={() => minuteStr, setMinute} disabled={isDisabled}>
109
+ <Select.Trigger
110
+ {size}
111
+ aria-label="Minute"
112
+ aria-describedby={describedBy}
113
+ aria-invalid={hasError || undefined}
114
+ aria-errormessage={errorMessageId}
115
+ >
99
116
  <span data-time-display data-placeholder={!minuteStr ? '' : undefined}>
100
117
  {minuteStr || 'MM'}
101
118
  </span>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dryui/ui",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "author": "Rob Balfre",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -401,6 +401,11 @@
401
401
  "svelte": "./dist/listbox/index.js",
402
402
  "default": "./dist/listbox/index.js"
403
403
  },
404
+ "./logo-mark": {
405
+ "types": "./dist/logo-mark/index.d.ts",
406
+ "svelte": "./dist/logo-mark/index.js",
407
+ "default": "./dist/logo-mark/index.js"
408
+ },
404
409
  "./map": {
405
410
  "types": "./dist/map/index.d.ts",
406
411
  "svelte": "./dist/map/index.js",
@@ -1141,6 +1146,11 @@
1141
1146
  "svelte": "./dist/listbox/index.js",
1142
1147
  "default": "./dist/listbox/index.js"
1143
1148
  },
1149
+ "./logo-mark": {
1150
+ "types": "./dist/logo-mark/index.d.ts",
1151
+ "svelte": "./dist/logo-mark/index.js",
1152
+ "default": "./dist/logo-mark/index.js"
1153
+ },
1144
1154
  "./map": {
1145
1155
  "types": "./dist/map/index.d.ts",
1146
1156
  "svelte": "./dist/map/index.js",
@@ -1504,7 +1514,7 @@
1504
1514
  "thumbnail:create": "bun scripts/create-thumbnail.ts"
1505
1515
  },
1506
1516
  "dependencies": {
1507
- "@dryui/primitives": "^0.1.12"
1517
+ "@dryui/primitives": "^0.2.0"
1508
1518
  },
1509
1519
  "peerDependencies": {
1510
1520
  "svelte": "^5.55.1"
@@ -31,6 +31,8 @@ Most DryUI components are compound — they require `<Card.Root>`, not `<Card>`.
31
31
  <!-- Right --> <Card.Root>content</Card.Root>
32
32
  ```
33
33
 
34
+ Compound components include Accordion, Alert, AlertDialog, Breadcrumb, Calendar, Card, Carousel, Chart, ChipGroup, Collapsible, ColorPicker, Combobox, CommandPalette, ContextMenu, DataGrid, DateField, DatePicker, DateRangePicker, DescriptionList, Dialog, DragAndDrop, Drawer, DropdownMenu, Field, Fieldset, FileSelect, FileUpload, FlipCard, FloatButton, HoverCard, InputGroup, LinkPreview, List, Listbox, Map, MegaMenu, Menubar, MultiSelectCombobox, NavigationMenu, NotificationCenter, OptionSwatchGroup, Pagination, PinInput, Popover, RadioGroup, RangeCalendar, RichTextEditor, SegmentedControl, Select, Sidebar, Splitter, StarRating, Stepper, Table, TableOfContents, Tabs, TagsInput, Timeline, Toast, ToggleGroup, Toolbar, Tooltip, Tour, Transfer, Tree and Typography.
35
+
34
36
  The test: every compound component in your markup uses `.Root`, and its parts are wrapped inside it. See `rules/compound-components.md` for the full list and parts reference.
35
37
 
36
38
  ## 3. Let the Theme Do Its Job
@@ -112,13 +114,20 @@ The test: search your markup for raw `<input`, `<select>`, `<dialog>`, `<button>
112
114
  - Codex: `$skill-installer install https://github.com/rob-balfre/dryui/tree/main/packages/ui/skills/dryui` then `codex mcp add dryui -- npx -y @dryui/mcp`
113
115
  - Copilot/Cursor/Windsurf: `npx degit rob-balfre/dryui/packages/ui/skills/dryui .agents/skills/dryui` + add MCP config (see https://dryui.dev/tools)
114
116
 
115
- **3. Run the install planner** — it detects your project and returns a tailored step-by-step plan:
117
+ **3. Bootstrap the project** — `init` detects your project state and applies whatever is missing:
116
118
 
117
119
  ```
118
- npx -y @dryui/cli install --toon
120
+ npx -y @dryui/cli init
119
121
  ```
120
122
 
121
- Execute each `"pending"` step. Then verify: `npx -y @dryui/cli detect --toon` output should show `project: ready`.
123
+ **New project from scratch?** Pass a directory name to scaffold SvelteKit + DryUI in one step:
124
+
125
+ ```
126
+ npx -y @dryui/cli init my-app
127
+ cd my-app && npm run dev
128
+ ```
129
+
130
+ This works for greenfield (empty directory), brownfield (existing non-SvelteKit project), and existing SvelteKit projects. Verify: `npx -y @dryui/cli detect --toon` — output should show `project: ready`.
122
131
 
123
132
  ### Manual setup
124
133
 
@@ -146,6 +155,13 @@ Always verify with `info`, but these are the most common mistakes:
146
155
 
147
156
  Use these to look up APIs, discover components, plan setup, and validate code.
148
157
 
158
+ ### Recommended workflow
159
+
160
+ 1. `compose` or `info` before writing components so you confirm kind, required parts, bindables, and canonical usage.
161
+ 2. Build the route or component with raw CSS grid, `Container` for constrained width, and `@container` for responsive layout.
162
+ 3. `review` or `doctor` after implementation to catch composition drift, layout violations, and accessibility regressions.
163
+ 4. Never guess component shape from memory. DryUI is intentionally strict, and the lookup cost is lower than rework.
164
+
149
165
  ### MCP tools (preferred)
150
166
 
151
167
  | Workflow | Tools |
@@ -160,6 +176,7 @@ Use these to look up APIs, discover components, plan setup, and validate code.
160
176
  All commands support `--toon` for token-optimized agent output and `--full` to disable truncation.
161
177
 
162
178
  ```bash
179
+ bunx @dryui/cli init [path] [--pm bun] # Bootstrap SvelteKit + DryUI project
163
180
  bunx @dryui/cli info <component> --toon # Look up component API
164
181
  bunx @dryui/cli compose "date input" --toon # Composition guidance
165
182
  bunx @dryui/cli detect [path] --toon # Check project setup
@@ -402,13 +402,62 @@ Call `compose` with any recipe name to get a full working snippet.
402
402
  | `data-table-with-actions` | Table with header actions | Table, Badge, Avatar, Button |
403
403
  | `checkout-flow` | Multi-step checkout | Stepper, Card, Field, RadioGroup |
404
404
  | `hotel-listing-card` | Product/listing card | Card, Image, Badge, Button, Text |
405
- | `stat-card-grid` | KPI dashboard cards | Grid, StatCard, Chart, Sparkline |
405
+ | `stat-card-grid` | KPI dashboard cards | StatCard, Chart, Sparkline, Container |
406
406
  | `settings-page` | Settings with tabs | Tabs, Card, Field, Input, Select |
407
407
  | `form-with-validation` | Form with error handling | Card, Field, Label, Input, Field.Error |
408
- | `sidebar-layout` | Page with sidebar nav | Grid, Sidebar, PageHeader |
409
- | `dashboard-page` | Full dashboard layout | Grid, Sidebar, StatCard, Chart, Table |
408
+ | `sidebar-layout` | Page with sidebar nav | Sidebar, PageHeader, Container |
409
+ | `dashboard-page` | Full dashboard layout | Sidebar, StatCard, Chart, Table |
410
410
  | `user-profile-card` | User info card | Card, Avatar, Text, Badge, Button |
411
411
  | `notification-list` | Notification feed | Card, Avatar, Text, Badge |
412
412
  | `command-bar` | Command palette trigger | CommandPalette, Hotkey |
413
413
  | `file-upload-form` | File upload with progress | Card, FileUpload, Progress, Button |
414
414
  | `pricing-table` | Pricing comparison | Card, Text, Button, Badge |
415
+
416
+ ## State-heavy form flows
417
+
418
+ DryUI is a presentation and accessibility system, not a workflow engine. For dependent-field planners, approvals, and booking-style state machines:
419
+
420
+ - Normalize route/session state in script before rendering DryUI inputs.
421
+ - Reset dependent `Select.Root` values when their parent choice changes; do not rely on stale child state surviving domain changes.
422
+ - Use raw CSS grid to lay out planner sections, and keep orchestration logic in route-level stores or derived state.
423
+ - Run `compose` or `info` before introducing a new field shape, then run `review` or `doctor` after the flow is wired.
424
+
425
+ ```svelte
426
+ <script lang="ts">
427
+ let country = $state('');
428
+ let airport = $state('');
429
+
430
+ const airportOptions = $derived(getAirports(country));
431
+
432
+ $effect(() => {
433
+ if (!airportOptions.some((option) => option.value === airport)) {
434
+ airport = '';
435
+ }
436
+ });
437
+ </script>
438
+
439
+ <div class="planner">
440
+ <Field.Root>
441
+ <Label>Country</Label>
442
+ <Select.Root bind:value={country}>
443
+ <Select.Trigger><Select.Value placeholder="Choose country" /></Select.Trigger>
444
+ <Select.Content>{/* items */}</Select.Content>
445
+ </Select.Root>
446
+ </Field.Root>
447
+
448
+ <Field.Root>
449
+ <Label>Airport</Label>
450
+ <Select.Root bind:value={airport} disabled={!country}>
451
+ <Select.Trigger><Select.Value placeholder="Choose airport" /></Select.Trigger>
452
+ <Select.Content>{/* filtered items */}</Select.Content>
453
+ </Select.Root>
454
+ </Field.Root>
455
+ </div>
456
+
457
+ <style>
458
+ .planner {
459
+ display: grid;
460
+ gap: var(--dry-space-4);
461
+ }
462
+ </style>
463
+ ```