@ggui-ai/ui-visual-tester 0.18.0 → 0.19.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.
@@ -156,9 +156,48 @@ const TEXT_LIKE_INPUT_TYPES = new Set([
156
156
  'text', 'email', 'search', 'url', 'tel', 'number', 'password', '',
157
157
  'date', 'time', 'month', 'week', 'datetime-local',
158
158
  ]);
159
+ /**
160
+ * #1040: dates are primed RELATIVE TO NOW — a form routinely refuses a date in the past
161
+ * ("preferred follow-up date"), and a constant sample date is a past date. Thirty days out,
162
+ * clamped into the input's own [min, max] when those are set in the input's format (ISO
163
+ * strings compare lexically). Time-of-day values have no past to fall into.
164
+ */
165
+ const pad2 = (n) => String(n).padStart(2, '0');
166
+ const partsOf = (at) => ({ y: at.getFullYear(), m: at.getMonth() + 1, d: at.getDate() });
167
+ /** Today and thirty days out, as the parts a date-like value is built from. */
168
+ function todayAndThirtyDaysOut() {
169
+ const now = new Date();
170
+ const at = new Date(now);
171
+ at.setDate(at.getDate() + 30);
172
+ return { today: partsOf(now), later: partsOf(at) };
173
+ }
174
+ /**
175
+ * Clamp a value into the input's [min, max] when each is set in the input's own format
176
+ * (ISO-shaped strings compare lexically). `min` later than the value wins; `max` earlier than
177
+ * the value wins only while it is not before `floor` (today in the same format) — a max in
178
+ * the past is a form that can never be satisfied, and the probe reports that as it is.
179
+ */
180
+ function clampToRange(el, value, floor, format) {
181
+ let out = value;
182
+ if (format.test(el.min) && el.min > out)
183
+ out = el.min;
184
+ if (format.test(el.max) && el.max < out && el.max >= floor)
185
+ out = el.max;
186
+ return out;
187
+ }
188
+ function isoWeekOf(y, m, d) {
189
+ // ISO-8601 week: Thursday of the same week decides the year.
190
+ const date = new Date(Date.UTC(y, m - 1, d));
191
+ const day = date.getUTCDay() || 7;
192
+ date.setUTCDate(date.getUTCDate() + 4 - day);
193
+ const jan1 = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
194
+ const week = Math.ceil(((date.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
195
+ return `${date.getUTCFullYear()}-W${pad2(week)}`;
196
+ }
159
197
  function sampleValueFor(el) {
160
- const type = el instanceof HTMLInputElement ? el.type : 'textarea';
161
- switch (type) {
198
+ if (!(el instanceof HTMLInputElement))
199
+ return 'probe'; // a textarea has no type to switch on
200
+ switch (el.type) {
162
201
  case 'number':
163
202
  return '1';
164
203
  case 'email':
@@ -167,16 +206,27 @@ function sampleValueFor(el) {
167
206
  return 'https://example.com';
168
207
  case 'tel':
169
208
  return '5550100';
170
- case 'date':
171
- return '2026-01-15';
209
+ case 'date': {
210
+ const { today, later } = todayAndThirtyDaysOut();
211
+ const iso = (p) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}`;
212
+ return clampToRange(el, iso(later), iso(today), /^\d{4}-\d{2}-\d{2}$/);
213
+ }
172
214
  case 'time':
173
215
  return '09:30';
174
- case 'month':
175
- return '2026-01';
176
- case 'week':
177
- return '2026-W03';
178
- case 'datetime-local':
179
- return '2026-01-15T09:30';
216
+ case 'month': {
217
+ const { today, later } = todayAndThirtyDaysOut();
218
+ const ym = (p) => `${p.y}-${pad2(p.m)}`;
219
+ return clampToRange(el, ym(later), ym(today), /^\d{4}-\d{2}$/);
220
+ }
221
+ case 'week': {
222
+ const { today, later } = todayAndThirtyDaysOut();
223
+ return clampToRange(el, isoWeekOf(later.y, later.m, later.d), isoWeekOf(today.y, today.m, today.d), /^\d{4}-W\d{2}$/);
224
+ }
225
+ case 'datetime-local': {
226
+ const { today, later } = todayAndThirtyDaysOut();
227
+ const at = (p, hm) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}T${hm}`;
228
+ return clampToRange(el, at(later, '09:30'), at(today, '00:00'), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
229
+ }
180
230
  default:
181
231
  return 'probe';
182
232
  }
@@ -20658,7 +20658,7 @@
20658
20658
  alignItems: "center",
20659
20659
  justifyContent: "center",
20660
20660
  gap: "var(--ggui-spacing-2, 8px)",
20661
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20661
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20662
20662
  fontWeight: "var(--ggui-font-weight-medium, 500)",
20663
20663
  cursor: isDisabled ? "not-allowed" : "pointer",
20664
20664
  opacity: isDisabled ? 0.5 : 1,
@@ -20704,7 +20704,7 @@
20704
20704
  }, children: [label, required && (0, import_jsx_runtime9.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime9.jsx)("input", { id: inputId, type, placeholder, value, onChange: (e) => onChange?.(e.target.value), disabled, required, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20705
20705
  ...sizeStyles2[size],
20706
20706
  width: "100%",
20707
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20707
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20708
20708
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20709
20709
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20710
20710
  color: "var(--ggui-color-onContainer, #18181b)",
@@ -20738,7 +20738,7 @@
20738
20738
  }, children: [label, required && (0, import_jsx_runtime10.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime10.jsx)("textarea", { id: textareaId, placeholder, value, onChange: (e) => onChange?.(e.target.value), rows, disabled, required, maxLength, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20739
20739
  padding: "10px 12px",
20740
20740
  width: "100%",
20741
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20741
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20742
20742
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20743
20743
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20744
20744
  color: "var(--ggui-color-onContainer, #18181b)",
@@ -20778,7 +20778,7 @@
20778
20778
  }, children: [label, required && (0, import_jsx_runtime11.jsx)("span", { "aria-hidden": "true", style: { color: "var(--ggui-color-error-500, #ef4444)", marginLeft: "2px" }, children: "*" })] }), (0, import_jsx_runtime11.jsxs)("select", { id: selectId, value, onChange: (e) => onChange?.(e.target.value), disabled, required, "aria-invalid": hasError || void 0, "aria-describedby": hasMessage ? messageId : void 0, style: {
20779
20779
  ...sizeStyles3[size],
20780
20780
  width: "100%",
20781
- borderRadius: "var(--ggui-shape-radius-md, 8px)",
20781
+ borderRadius: "var(--ggui-shape-radius-control, var(--ggui-shape-radius-md, 8px))",
20782
20782
  border: `1px solid ${hasError ? "var(--ggui-color-error-500, #ef4444)" : "var(--ggui-color-outline, #d4d4d8)"}`,
20783
20783
  backgroundColor: disabled ? "var(--ggui-color-sunken, #fafafa)" : "var(--ggui-color-sunken, #ffffff)",
20784
20784
  color: value ? "var(--ggui-color-onContainer, #18181b)" : "var(--ggui-color-onSunken, #52525b)",
@@ -28618,6 +28618,7 @@
28618
28618
  if (onDispatchSuppressed) {
28619
28619
  try {
28620
28620
  onDispatchSuppressed({
28621
+ reason: "duplicate-dispatch",
28621
28622
  actionName,
28622
28623
  payloadSignature: decision.signature,
28623
28624
  payload: data,
@@ -29372,11 +29373,26 @@
29372
29373
  return internalSeq;
29373
29374
  });
29374
29375
  const validateEnvelope = opts.validateEnvelope ?? validateOutboundActionEnvelope;
29376
+ const spentOneShots = /* @__PURE__ */ new Set();
29375
29377
  return {
29376
29378
  app: opts.app,
29377
29379
  render: opts.render,
29378
29380
  auth: opts.auth,
29381
+ ...opts.onDispatchSuppressed !== void 0 ? { onDispatchSuppressed: opts.onDispatchSuppressed } : {},
29379
29382
  dispatch: (actionName, data) => {
29383
+ const actionSpec = opts.getActiveActionSpec();
29384
+ const isOneShot = actionSpec?.[actionName]?.oneShot === true;
29385
+ if (isOneShot && spentOneShots.has(actionName)) {
29386
+ console.warn(`[ggui] dispatch('${actionName}') suppressed: this action is declared oneShot and already fired for this render. A second gesture is NOT sent to the agent \u2014 the submitted control is spent for the render's lifetime. If the action is meant to repeat, remove \`oneShot\` from its contract entry.`);
29387
+ opts.onDispatchSuppressed?.({
29388
+ reason: "one-shot-spent",
29389
+ actionName,
29390
+ payloadSignature: payloadSignature(actionName, data),
29391
+ payload: data,
29392
+ suppressedAt: Date.now()
29393
+ });
29394
+ return;
29395
+ }
29380
29396
  const envelope = buildActionEnvelope({
29381
29397
  sessionId: opts.render.sessionId,
29382
29398
  type: "data:submit",
@@ -29386,11 +29402,13 @@
29386
29402
  },
29387
29403
  clientSeq: nextClientSeq()
29388
29404
  });
29389
- const result = validateEnvelope(opts.getActiveActionSpec(), envelope);
29405
+ const result = validateEnvelope(actionSpec, envelope);
29390
29406
  if (!result.valid) {
29391
29407
  opts.onViolation(new ClientContractViolationError("outbound-action", result.violations));
29392
29408
  return;
29393
29409
  }
29410
+ if (isOneShot)
29411
+ spentOneShots.add(actionName);
29394
29412
  opts.emitEnvelope(envelope);
29395
29413
  },
29396
29414
  subscribe: (channelName, handler) => {
@@ -29543,6 +29561,7 @@
29543
29561
  "--ggui-font-weight-normal",
29544
29562
  "--ggui-font-weight-semibold",
29545
29563
  "--ggui-letter-spacing-heading",
29564
+ "--ggui-shape-radius-control",
29546
29565
  "--ggui-shape-radius-full",
29547
29566
  "--ggui-shape-radius-lg",
29548
29567
  "--ggui-shape-radius-md",
@@ -32879,9 +32898,31 @@
32879
32898
  "week",
32880
32899
  "datetime-local"
32881
32900
  ]);
32901
+ var pad2 = (n) => String(n).padStart(2, "0");
32902
+ var partsOf = (at) => ({ y: at.getFullYear(), m: at.getMonth() + 1, d: at.getDate() });
32903
+ function todayAndThirtyDaysOut() {
32904
+ const now = /* @__PURE__ */ new Date();
32905
+ const at = new Date(now);
32906
+ at.setDate(at.getDate() + 30);
32907
+ return { today: partsOf(now), later: partsOf(at) };
32908
+ }
32909
+ function clampToRange(el, value, floor, format) {
32910
+ let out = value;
32911
+ if (format.test(el.min) && el.min > out) out = el.min;
32912
+ if (format.test(el.max) && el.max < out && el.max >= floor) out = el.max;
32913
+ return out;
32914
+ }
32915
+ function isoWeekOf(y, m, d) {
32916
+ const date = new Date(Date.UTC(y, m - 1, d));
32917
+ const day = date.getUTCDay() || 7;
32918
+ date.setUTCDate(date.getUTCDate() + 4 - day);
32919
+ const jan1 = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
32920
+ const week = Math.ceil(((date.getTime() - jan1.getTime()) / 864e5 + 1) / 7);
32921
+ return `${date.getUTCFullYear()}-W${pad2(week)}`;
32922
+ }
32882
32923
  function sampleValueFor(el) {
32883
- const type = el instanceof HTMLInputElement ? el.type : "textarea";
32884
- switch (type) {
32924
+ if (!(el instanceof HTMLInputElement)) return "probe";
32925
+ switch (el.type) {
32885
32926
  case "number":
32886
32927
  return "1";
32887
32928
  case "email":
@@ -32890,16 +32931,27 @@
32890
32931
  return "https://example.com";
32891
32932
  case "tel":
32892
32933
  return "5550100";
32893
- case "date":
32894
- return "2026-01-15";
32934
+ case "date": {
32935
+ const { today, later } = todayAndThirtyDaysOut();
32936
+ const iso = (p) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}`;
32937
+ return clampToRange(el, iso(later), iso(today), /^\d{4}-\d{2}-\d{2}$/);
32938
+ }
32895
32939
  case "time":
32896
32940
  return "09:30";
32897
- case "month":
32898
- return "2026-01";
32899
- case "week":
32900
- return "2026-W03";
32901
- case "datetime-local":
32902
- return "2026-01-15T09:30";
32941
+ case "month": {
32942
+ const { today, later } = todayAndThirtyDaysOut();
32943
+ const ym = (p) => `${p.y}-${pad2(p.m)}`;
32944
+ return clampToRange(el, ym(later), ym(today), /^\d{4}-\d{2}$/);
32945
+ }
32946
+ case "week": {
32947
+ const { today, later } = todayAndThirtyDaysOut();
32948
+ return clampToRange(el, isoWeekOf(later.y, later.m, later.d), isoWeekOf(today.y, today.m, today.d), /^\d{4}-W\d{2}$/);
32949
+ }
32950
+ case "datetime-local": {
32951
+ const { today, later } = todayAndThirtyDaysOut();
32952
+ const at = (p, hm) => `${p.y}-${pad2(p.m)}-${pad2(p.d)}T${hm}`;
32953
+ return clampToRange(el, at(later, "09:30"), at(today, "00:00"), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
32954
+ }
32903
32955
  default:
32904
32956
  return "probe";
32905
32957
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ggui-ai/ui-visual-tester",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Behavioral validator for ggui-generated component code. Renders a component in real Chromium via Playwright, clicks each action declared in its contract, and verifies the action actually does something — mutating bound state or dispatching to the agent. Catches buttons wired to no-op handlers that structural validators miss. Playwright is an optional peer dependency, injected by the caller.",
5
5
  "keywords": [
6
6
  "ggui",
@@ -48,9 +48,9 @@
48
48
  "dependencies": {
49
49
  "react": "^19.0.0",
50
50
  "react-dom": "^19.0.0",
51
- "@ggui-ai/design": "0.18.0",
52
- "@ggui-ai/wire": "0.18.0",
53
- "@ggui-ai/protocol": "0.18.0"
51
+ "@ggui-ai/design": "0.19.0",
52
+ "@ggui-ai/wire": "0.19.0",
53
+ "@ggui-ai/protocol": "0.19.0"
54
54
  },
55
55
  "devDependencies": {
56
56
  "@playwright/test": "^1.60.0",