anentrypoint-design 0.0.173 → 0.0.175

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anentrypoint-design",
3
- "version": "0.0.173",
3
+ "version": "0.0.175",
4
4
  "description": "247420 design system SDK — webjsx + modified ripple-ui, single-file ESM bundle for reproducible use of the AnEntrypoint design.",
5
5
  "type": "module",
6
6
  "main": "./dist/247420.js",
@@ -109,7 +109,12 @@ export function WorksList({ works = [], openedIndex = -1, onToggle }) {
109
109
  Row({
110
110
  code: w.code,
111
111
  title: w.title, sub: w.sub,
112
- meta: w.meta + ' ' + (isOpen ? '-' : '+'),
112
+ // Expand affordance: a chevron icon (down when open, right when
113
+ // collapsed) separated from the meta text by a CSS gap, not a
114
+ // literal +/- with a double-space.
115
+ meta: h('span', { class: 'ds-works-meta', style: 'display:inline-flex;align-items:center;gap:.4em' },
116
+ w.meta != null ? h('span', {}, w.meta) : null,
117
+ Icon(isOpen ? 'chevron-down' : 'chevron-right')),
113
118
  active: isOpen,
114
119
  onClick: () => onToggle && onToggle(isOpen ? -1 : i)
115
120
  }),
@@ -163,15 +168,19 @@ export function Table({ headers = [], rows = [], onRowClick, emptyText = 'nothin
163
168
  const c = row[0];
164
169
  return c == null ? 'row' : (typeof c === 'object' ? 'row' : String(c));
165
170
  };
166
- return h('table', { role: 'table' },
167
- h('thead', {}, h('tr', { role: 'row' }, ...headers.map((hd, i) => h('th', { key: i, scope: 'col', role: 'columnheader' }, hd)))),
171
+ // Native <table>/<tr>/<th>/<td> already carry the correct implicit ARIA
172
+ // roles explicit role="table"/row/columnheader/cell is redundant and only
173
+ // risks overriding native semantics, so it is omitted.
174
+ return h('table', {},
175
+ h('thead', {}, h('tr', {}, ...headers.map((hd, i) => h('th', { key: i, scope: 'col' }, hd)))),
168
176
  h('tbody', {}, ...rows.map((row, i) => h('tr', {
169
177
  key: i,
170
178
  class: onRowClick ? 'clickable' : '',
171
- role: 'row',
172
179
  onclick: onRowClick ? () => onRowClick(i) : null,
173
- ...(onRowClick ? { tabindex: '0', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onRowClick(i); } } } : {})
174
- }, ...row.map((c, j) => h('td', { key: j, role: 'cell' }, c == null ? '' : (typeof c === 'object' ? c : String(c))))))));
180
+ // Space scrolls by default preventDefault on Space (and Enter) so
181
+ // keyboard activation matches click without page jump.
182
+ ...(onRowClick ? { tabindex: '0', role: 'button', 'aria-label': 'open ' + labelFor(row, i), onkeydown: (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onRowClick(i); } } } : {})
183
+ }, ...row.map((c, j) => h('td', { key: j }, c == null ? '' : (typeof c === 'object' ? c : String(c))))))));
175
184
  }
176
185
 
177
186
  export function HomeView({ state = {}, onNav, onToggleWork, works = [], posts = [], manifesto = [], currentlyShipping } = {}) {
@@ -256,17 +265,21 @@ export function SearchInput({ value = '', placeholder = 'search…', onInput, on
256
265
  });
257
266
  }
258
267
 
259
- export function TextField({ label, value = '', type = 'text', placeholder = '', onInput, onChange, name, key, hint, multiline, rows = 4, maxLength }) {
268
+ export function TextField({ label, value = '', type = 'text', placeholder = '', onInput, onChange, name, key, hint, multiline, rows = 4, maxLength, min, max, 'aria-label': ariaLabel }) {
260
269
  const input = multiline
261
270
  ? h('textarea', {
262
271
  key: 'i', name, rows, placeholder, value,
263
272
  maxlength: maxLength != null ? maxLength : null,
273
+ 'aria-label': ariaLabel || null,
264
274
  oninput: onInput ? (e) => onInput(e.target.value, e) : null,
265
275
  onchange: onChange ? (e) => onChange(e.target.value, e) : null
266
276
  })
267
277
  : h('input', {
268
278
  key: 'i', type, name, placeholder, value,
269
279
  maxlength: maxLength != null ? maxLength : null,
280
+ min: min != null ? String(min) : null,
281
+ max: max != null ? String(max) : null,
282
+ 'aria-label': ariaLabel || null,
270
283
  oninput: onInput ? (e) => onInput(e.target.value, e) : null,
271
284
  onchange: onChange ? (e) => onChange(e.target.value, e) : null
272
285
  });
@@ -320,9 +333,19 @@ export function EventList({ items, events, emptyText = 'no events', rankPad = 3
320
333
  export function Form({ fields = [], submit = 'submit', onSubmit, columns = 1 }) {
321
334
  const cols = columns > 1 ? String(columns) : null;
322
335
  return h('form', { class: 'row-form', 'data-columns': cols, onsubmit: (ev) => { ev.preventDefault(); onSubmit && onSubmit(ev); } },
323
- ...fields.map((f, i) => f.kind === 'textarea'
324
- ? h('textarea', { key: i, name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4 })
325
- : h('input', { key: i, name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? 'true' : null })),
336
+ ...fields.map((f, i) => {
337
+ // Each control gets a stable id and an associated <label> so the
338
+ // placeholder is no longer the only (inaccessible) name. The label
339
+ // text falls back to label -> placeholder -> name.
340
+ const fieldId = 'ds-form-' + (f.name || 'field') + '-' + i;
341
+ const labelText = f.label != null ? f.label : (f.placeholder || f.name || '');
342
+ const control = f.kind === 'textarea'
343
+ ? h('textarea', { key: 'i', id: fieldId, name: f.name, placeholder: f.placeholder || '', rows: f.rows || 4, required: f.required ? true : null })
344
+ : h('input', { key: 'i', id: fieldId, name: f.name, type: f.type || 'text', placeholder: f.placeholder || '', value: f.value || '', required: f.required ? true : null });
345
+ return h('label', { key: i, class: 'ds-field', for: fieldId },
346
+ labelText !== '' ? h('span', { key: 'l', class: 'ds-field-label' }, labelText) : null,
347
+ control);
348
+ }),
326
349
  h('button', { type: 'submit', class: 'btn-primary' }, submit));
327
350
  }
328
351
 
@@ -339,13 +362,31 @@ export function Spinner({ size = 'base', tone = 'accent', label = 'loading', key
339
362
  );
340
363
  }
341
364
 
365
+ // Clamp a caller-supplied CSS length to a sane range so a raw prop like
366
+ // height="9999px" can't blow out the layout. Accepts a CSS length string
367
+ // (px/em/rem/%/vh/vw) or a bare number (treated as px); rejects anything else
368
+ // back to the default. Numeric values are clamped to [2, 600] (px-equivalent).
369
+ function clampLen(v, fallback) {
370
+ if (v == null) return fallback;
371
+ const s = String(v).trim();
372
+ const m = /^(\d+(?:\.\d+)?)(px|em|rem|%|vh|vw)?$/.exec(s);
373
+ if (!m) return fallback;
374
+ const unit = m[2] || 'px';
375
+ let n = parseFloat(m[1]);
376
+ if (unit === '%' || unit === 'vh' || unit === 'vw') n = Math.min(100, Math.max(0, n));
377
+ else n = Math.min(600, Math.max(2, n));
378
+ return n + unit;
379
+ }
380
+
342
381
  export function Skeleton({ height = '1em', width = '100%', count = 1, label = 'loading content', key } = {}) {
382
+ const h_ = clampLen(height, '1em');
383
+ const w_ = clampLen(width, '100%');
343
384
  return h('div', {
344
385
  key, class: 'ds-skeleton-group',
345
386
  role: 'status', 'aria-busy': 'true', 'aria-label': label
346
387
  },
347
388
  ...Array(count).fill(0).map((_, i) =>
348
- h('div', { key: String(i), class: 'ds-skeleton', style: `height:${height};width:${width};`, 'aria-hidden': 'true' })
389
+ h('div', { key: String(i), class: 'ds-skeleton', style: `height:${h_};width:${w_};`, 'aria-hidden': 'true' })
349
390
  )
350
391
  );
351
392
  }
@@ -272,6 +272,14 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
272
272
  const sizeProp = isH ? 'width' : 'height';
273
273
  const initStyle = typeof initial === 'number' ? initial + 'px' : initial;
274
274
  let rootEl = null;
275
+ // The dragged size is persisted here so a re-render (applyDiff reconciling
276
+ // the pane's style back to the initial value) does NOT reset the user's
277
+ // resize. onResize records it; the pane's ref re-applies it after each diff.
278
+ let draggedSize = null;
279
+ const applySize = (a) => {
280
+ if (!a) return;
281
+ if (draggedSize != null) { a.style[sizeProp] = draggedSize + 'px'; a.style.flex = '0 0 auto'; }
282
+ };
275
283
  const onResize = (delta) => {
276
284
  if (!rootEl) return;
277
285
  const a = rootEl.firstChild;
@@ -280,6 +288,7 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
280
288
  const curr = isH ? rect.width : rect.height;
281
289
  const total = isH ? rootEl.getBoundingClientRect().width : rootEl.getBoundingClientRect().height;
282
290
  const next = Math.max(min, Math.min(max === Infinity ? total - min : max, curr + delta));
291
+ draggedSize = next;
283
292
  a.style[sizeProp] = next + 'px';
284
293
  a.style.flex = '0 0 auto';
285
294
  };
@@ -287,7 +296,7 @@ export function SplitPanel({ orientation = 'horizontal', initial = '50%', min =
287
296
  class: 'ds-ep-split ' + (isH ? 'horiz' : 'vert'),
288
297
  ref: (el) => { rootEl = el; }
289
298
  },
290
- h('div', { class: 'ds-ep-split-pane', style: '--split-size:' + initStyle + ';flex:0 0 auto' }, first),
299
+ h('div', { class: 'ds-ep-split-pane', style: '--split-size:' + initStyle + ';flex:0 0 auto', ref: applySize }, first),
291
300
  ResizeHandle({ axis: isH ? 'horizontal' : 'vertical', onResize }),
292
301
  h('div', { class: 'ds-ep-split-pane grow', style: 'flex:1 1 0;min-' + sizeProp + ':0' }, second)
293
302
  );
@@ -6,14 +6,18 @@ import { fileGlyph, fmtFileSize } from './files.js';
6
6
  const h = webjsx.createElement;
7
7
 
8
8
  function Backdrop({ onClose, children, kind = '' } = {}) {
9
+ // webjsx invokes a ref callback with the element on mount and with null on
10
+ // unmount. We stash the per-element keydown teardown on the node itself so
11
+ // the null branch can run it — otherwise the document/element listener leaks
12
+ // once the modal is removed.
9
13
  const backdropRef = (el) => {
10
- if (!el) return;
14
+ if (!el) return; // unmount (ref(null)) handled by wrapper below
11
15
  const modal = el.querySelector('.ds-modal');
12
16
  if (!modal) return;
13
17
 
14
18
  // Focus trap: handle Tab key to cycle focus within modal
15
19
  const focusables = modal.querySelectorAll(
16
- 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
20
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
17
21
  );
18
22
  const firstFocusable = focusables[0];
19
23
  const lastFocusable = focusables[focusables.length - 1];
@@ -46,75 +50,86 @@ function Backdrop({ onClose, children, kind = '' } = {}) {
46
50
  };
47
51
 
48
52
  el.addEventListener('keydown', handleKeydown);
49
- // Auto-focus first focusable element on mount
50
- if (firstFocusable && document.activeElement === document.body) {
51
- firstFocusable.focus();
52
- }
53
-
54
- return () => el.removeEventListener('keydown', handleKeydown);
53
+ el._dsModalTeardown = () => el.removeEventListener('keydown', handleKeydown);
54
+ // Auto-focus on open always, not only when focus sits on <body>.
55
+ // Prefer an element explicitly marked [autofocus].
56
+ const preferred = modal.querySelector('[autofocus]') || firstFocusable;
57
+ if (preferred) preferred.focus();
55
58
  };
56
59
 
57
60
  return h('div', {
58
61
  class: 'ds-modal-backdrop',
59
- ref: backdropRef,
62
+ ref: (el) => {
63
+ if (el) backdropRef(el);
64
+ else if (Backdrop._last && Backdrop._last._dsModalTeardown) { Backdrop._last._dsModalTeardown(); Backdrop._last = null; }
65
+ if (el) Backdrop._last = el;
66
+ },
60
67
  onclick: (e) => { if (e.target === e.currentTarget && onClose) onClose(); }
61
68
  },
62
69
  h('div', { class: 'ds-modal' + (kind ? ' ds-modal-' + kind : '') }, ...(Array.isArray(children) ? children : [children]))
63
70
  );
64
71
  }
65
72
 
66
- export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel } = {}) {
73
+ // Shared modal shell: head + body + actions row. ConfirmDialog/PromptDialog/
74
+ // FileViewer all funnel through this so the ds-modal markup is authored once.
75
+ // `actions` is an array of vnodes (already using the Btn primitive). Any of the
76
+ // slots may be omitted.
77
+ function Modal({ onClose, kind = '', head, headClass = '', headAttrs = {}, body, bodyClass = 'ds-modal-body', bodyAttrs = {}, actions } = {}) {
67
78
  return Backdrop({
79
+ onClose,
80
+ kind,
81
+ children: [
82
+ head != null ? h('div', { class: ('ds-modal-head' + (headClass ? ' ' + headClass : '')), ...headAttrs }, ...(Array.isArray(head) ? head : [head])) : null,
83
+ body != null ? h('div', { class: bodyClass, ...bodyAttrs }, ...(Array.isArray(body) ? body : [body])) : null,
84
+ actions != null ? h('div', { class: 'ds-modal-actions' }, ...(Array.isArray(actions) ? actions : [actions])) : null,
85
+ ].filter(Boolean)
86
+ });
87
+ }
88
+
89
+ export function ConfirmDialog({ title = 'confirm', message, confirmLabel = 'confirm', cancelLabel = 'cancel', destructive, onConfirm, onCancel } = {}) {
90
+ return Modal({
68
91
  onClose: onCancel,
69
92
  kind: 'small',
70
- children: [
71
- h('div', { class: 'ds-modal-head' }, title),
72
- h('div', { class: 'ds-modal-body' }, message || ''),
73
- h('div', { class: 'ds-modal-actions' },
74
- Btn({ onClick: onCancel, children: cancelLabel }),
75
- h('button', {
76
- class: destructive ? 'btn-primary danger' : 'btn-primary',
77
- onclick: onConfirm
78
- }, confirmLabel)
79
- )
93
+ head: title,
94
+ body: message || '',
95
+ actions: [
96
+ Btn({ onClick: onCancel, children: cancelLabel }),
97
+ Btn({ primary: true, danger: !!destructive, onClick: onConfirm, children: confirmLabel })
80
98
  ]
81
99
  });
82
100
  }
83
101
 
84
102
  export function PromptDialog({ title = 'name', value = '', placeholder = '', confirmLabel = 'ok', cancelLabel = 'cancel', onConfirm, onCancel, onInput } = {}) {
85
- return Backdrop({
103
+ return Modal({
86
104
  onClose: onCancel,
87
105
  kind: 'small',
88
- children: [
89
- h('div', { class: 'ds-modal-head' }, title),
90
- h('div', { class: 'ds-modal-body' },
91
- h('input', {
92
- class: 'input ds-modal-input',
93
- type: 'text',
94
- value,
95
- placeholder,
96
- autofocus: true,
97
- oninput: (e) => onInput && onInput(e.target.value),
98
- onkeydown: (e) => {
99
- if (e.key === 'Enter') { e.preventDefault(); onConfirm && onConfirm(e.target.value); }
100
- if (e.key === 'Escape') { e.preventDefault(); onCancel && onCancel(); }
101
- }
102
- })
103
- ),
104
- h('div', { class: 'ds-modal-actions' },
105
- Btn({ onClick: onCancel, children: cancelLabel }),
106
- h('button', {
107
- class: 'btn-primary',
108
- // Read the live input value, not the closed-over `value` prop:
109
- // consumers update their state in oninput without re-rendering
110
- // (to avoid caret jump), so the prop is stale at click time.
111
- onclick: (e) => {
112
- if (!onConfirm) return;
113
- const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
114
- onConfirm(inp ? inp.value : value);
115
- }
116
- }, confirmLabel)
117
- )
106
+ head: title,
107
+ body: h('input', {
108
+ class: 'input ds-modal-input',
109
+ type: 'text',
110
+ value,
111
+ placeholder,
112
+ autofocus: true,
113
+ oninput: (e) => onInput && onInput(e.target.value),
114
+ onkeydown: (e) => {
115
+ if (e.key === 'Enter') { e.preventDefault(); onConfirm && onConfirm(e.target.value); }
116
+ if (e.key === 'Escape') { e.preventDefault(); onCancel && onCancel(); }
117
+ }
118
+ }),
119
+ actions: [
120
+ Btn({ onClick: onCancel, children: cancelLabel }),
121
+ Btn({
122
+ primary: true,
123
+ // Read the live input value, not the closed-over `value` prop:
124
+ // consumers update their state in oninput without re-rendering
125
+ // (to avoid caret jump), so the prop is stale at click time.
126
+ onClick: (e) => {
127
+ if (!onConfirm) return;
128
+ const inp = e.currentTarget.closest('.ds-modal')?.querySelector('.ds-modal-input');
129
+ onConfirm(inp ? inp.value : value);
130
+ },
131
+ children: confirmLabel
132
+ })
118
133
  ]
119
134
  });
120
135
  }
@@ -124,7 +139,7 @@ export function FilePreviewMedia({ src, type = 'other', name } = {}) {
124
139
  if (type === 'video') return h('video', { class: 'ds-preview-media', src, controls: true });
125
140
  if (type === 'audio') return h('audio', { class: 'ds-preview-audio', src, controls: true });
126
141
  return h('div', { class: 'ds-preview-fallback' },
127
- h('span', { class: 'ds-preview-glyph' }, fileGlyph(type)),
142
+ h('span', { class: 'ds-preview-glyph', 'aria-hidden': 'true' }, Icon(fileGlyph(type))),
128
143
  h('span', {}, 'no inline preview for ' + (type || 'this file'))
129
144
  );
130
145
  }
@@ -146,21 +161,21 @@ export function FileViewer({ file, body, onClose, onAction } = {}) {
146
161
  if (!file) return null;
147
162
  const meta = [file.type, file.size != null ? fmtFileSize(file.size) : null, file.modified || null]
148
163
  .filter(Boolean).join(' · ');
149
- return Backdrop({
164
+ return Modal({
150
165
  onClose,
151
166
  kind: 'preview',
152
- children: [
153
- h('div', { class: 'ds-modal-head ds-preview-head', 'data-file-type': file.type || 'other' },
154
- h('span', { class: 'ds-preview-name' }, file.name || ''),
155
- h('span', { class: 'ds-preview-meta' }, meta),
156
- h('span', { class: 'ds-preview-actions' },
157
- onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
158
- h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
159
- )
160
- ),
161
- h('div', { class: 'ds-preview-body', 'data-file-type': file.type || 'other' },
162
- ...(Array.isArray(body) ? body : [body])
167
+ headClass: 'ds-preview-head',
168
+ headAttrs: { 'data-file-type': file.type || 'other' },
169
+ head: [
170
+ h('span', { class: 'ds-preview-name' }, file.name || ''),
171
+ h('span', { class: 'ds-preview-meta' }, meta),
172
+ h('span', { class: 'ds-preview-actions' },
173
+ onAction ? h('button', { class: 'ds-file-act', title: 'download', 'aria-label': 'download', onclick: () => onAction('download') }, Icon('arrow-down')) : null,
174
+ h('button', { class: 'ds-file-act', title: 'close', 'aria-label': 'close', onclick: onClose }, Icon('x'))
163
175
  )
164
- ]
176
+ ],
177
+ bodyClass: 'ds-preview-body',
178
+ bodyAttrs: { 'data-file-type': file.type || 'other' },
179
+ body: Array.isArray(body) ? body : [body],
165
180
  });
166
181
  }
@@ -4,6 +4,10 @@ import * as webjsx from '../../vendor/webjsx/index.js';
4
4
  import { Btn, Icon } from './shell.js';
5
5
  const h = webjsx.createElement;
6
6
 
7
+ // Minimum column width for the responsive file grid (minmax floor). Named so
8
+ // the magic 240px isn't buried in the gridTemplateColumns string.
9
+ const FILE_GRID_MIN_COL = '240px';
10
+
7
11
  const FILE_TYPES = ['dir', 'image', 'video', 'audio', 'code', 'text', 'archive', 'document', 'symlink', 'other'];
8
12
  const TYPE_ICON = {
9
13
  dir: 'file', image: 'file', video: 'file-video', audio: 'file-audio', code: 'file-code',
@@ -43,27 +47,29 @@ export function FileRow({ name, type = 'other', size, modified, code, onOpen, on
43
47
  const meta = [type === 'dir' ? null : fmtFileSize(size), modified || null].filter(Boolean).join(' · ');
44
48
  const typeLabel = TYPE_LABELS[type] || 'file';
45
49
  const accessibleLabel = `${typeLabel}: ${name}${meta ? ` (${meta})` : ''}`;
50
+ // A role=button row containing real <button> action controls is invalid
51
+ // HTML (interactive nesting). Instead the row is a plain container and the
52
+ // primary "open" affordance is itself a real <button> (native keyboard +
53
+ // semantics); the per-file action buttons sit alongside it as siblings.
46
54
  return h('div', {
47
55
  key,
48
56
  class: 'ds-file-row row' + (active ? ' active' : ''),
49
57
  'data-file-type': type,
50
- onclick: onOpen,
51
- role: 'button',
52
- tabindex: '0',
53
- 'aria-label': accessibleLabel,
54
- 'aria-pressed': active ? 'true' : 'false',
55
- onkeydown: (e) => {
56
- if (e.key === 'Enter' || e.key === ' ') {
57
- e.preventDefault();
58
- onOpen && onOpen();
59
- }
60
- }
61
58
  },
62
- code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
63
- FileIcon({ type }),
64
- h('span', { class: 'title' }, name),
65
- h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—'),
66
- onAction ? h('span', { class: 'ds-file-actions', onclick: (e) => e.stopPropagation(), role: 'group', 'aria-label': `actions for ${name}` },
59
+ h('button', {
60
+ type: 'button',
61
+ class: 'ds-file-open',
62
+ onclick: onOpen || null,
63
+ 'aria-label': accessibleLabel,
64
+ 'aria-pressed': active ? 'true' : 'false',
65
+ disabled: onOpen ? null : true,
66
+ },
67
+ code != null ? h('span', { class: 'code', 'aria-label': `code: ${code}` }, code) : null,
68
+ FileIcon({ type }),
69
+ h('span', { class: 'title' }, name),
70
+ h('span', { class: 'ds-file-meta meta', 'aria-label': meta ? `metadata: ${meta}` : null }, meta || '—')
71
+ ),
72
+ onAction ? h('span', { class: 'ds-file-actions', role: 'group', 'aria-label': `actions for ${name}` },
67
73
  h('button', { class: 'ds-file-act', title: 'download', 'aria-label': `download ${name}`, onclick: () => onAction('download') }, Icon('arrow-down')),
68
74
  h('button', { class: 'ds-file-act', title: 'rename', 'aria-label': `rename ${name}`, onclick: () => onAction('rename') }, Icon('pencil')),
69
75
  h('button', { class: 'ds-file-act ds-file-act-warn', title: 'delete', 'aria-label': `delete ${name}`, onclick: () => onAction('delete') }, Icon('x'))
@@ -79,7 +85,7 @@ export function FileGrid({ files = [], onOpen, onAction, emptyText = 'no files h
79
85
  gridAttrs['data-columns'] = String(col);
80
86
  gridAttrs.style = {
81
87
  display: 'grid',
82
- gridTemplateColumns: `repeat(${col}, minmax(240px, 1fr))`,
88
+ gridTemplateColumns: `repeat(${col}, minmax(${FILE_GRID_MIN_COL}, 1fr))`,
83
89
  gap: 'var(--space-3)'
84
90
  };
85
91
  }
@@ -108,7 +114,7 @@ export function DropZone({ children, dragover, onDrop, onDragOver, onDragLeave,
108
114
  ondrop: (e) => { e.preventDefault(); onDrop && onDrop(e.dataTransfer.files); }
109
115
  },
110
116
  h('div', { class: 'ds-dropzone-inner' },
111
- h('span', { class: 'ds-dropzone-glyph' }, ''),
117
+ h('span', { class: 'ds-dropzone-glyph', role: 'img', 'aria-label': 'upload' }, Icon('arrow-up')),
112
118
  h('span', { class: 'ds-dropzone-label' }, label),
113
119
  onPick ? Btn({ onClick: onPick, children: 'pick files' }) : null
114
120
  ),
@@ -142,8 +148,8 @@ export function UploadProgress({ items = [] } = {}) {
142
148
  }
143
149
 
144
150
  export function EmptyState({ text = 'nothing here', glyph = Icon('circle') } = {}) {
145
- return h('div', { class: 'ds-file-empty' },
146
- h('span', { class: 'ds-file-empty-glyph' }, glyph),
151
+ return h('div', { class: 'ds-file-empty', role: 'status' },
152
+ h('span', { class: 'ds-file-empty-glyph', 'aria-hidden': 'true' }, glyph),
147
153
  h('span', { class: 'ds-file-empty-text' }, text)
148
154
  );
149
155
  }
@@ -116,13 +116,16 @@ export function Field({ label, hint, error, required, requiredMarker = '*', html
116
116
  const errorId = error != null ? autoId + '-err' : null;
117
117
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || null;
118
118
  const list = Array.isArray(children) ? children : [children];
119
+ // Apply the generated id to the FIRST control that lacks one so the label's
120
+ // `for=autoId` and the hint/error aria-describedby actually reference it.
121
+ // Controls that already carry an id keep theirs (and still get describedby).
122
+ let idApplied = false;
119
123
  const decorated = list.map((c) => {
120
124
  if (!c || typeof c !== 'object') return c;
121
125
  const props = c.props || {};
122
126
  const extra = { 'aria-describedby': describedBy };
123
127
  if (error != null) extra['aria-invalid'] = 'true';
124
- if (!props.id && !htmlFor) { /* leave id unset */ }
125
- else if (!props.id) extra.id = autoId;
128
+ if (!props.id && !idApplied) { extra.id = autoId; idApplied = true; }
126
129
  return cloneWithProps(c, extra);
127
130
  });
128
131
  return h('div', { key, class: 'ds-field-wrap' },
@@ -99,7 +99,7 @@ export function errorState(err, onRetry) {
99
99
  h('div', { class: 'ds-alert-content' },
100
100
  h('div', { class: 'ds-alert-title' }, 'failed to load'),
101
101
  h('div', { class: 'ds-alert-message' }, msg),
102
- onRetry ? h('button', { class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
102
+ onRetry ? h('button', { type: 'button', class: 'btn ds-alert-retry', onclick: onRetry }, 'retry') : null));
103
103
  }
104
104
 
105
105
  export function emptyState(text = 'nothing here yet', glyph = Icon('circle')) {