@qontinui/ui-bridge-native 0.6.1 → 0.6.3

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.mjs CHANGED
@@ -349,6 +349,51 @@ var NativeUIBridgeRegistry = class {
349
349
  this.emit("element:stateChanged", { id, state: newState });
350
350
  }
351
351
  }
352
+ /**
353
+ * Update an element's descriptive metadata (label / accessibilityLabel /
354
+ * testId). Sister to {@link updateElementState} (which carries layout +
355
+ * visibility) and {@link updateElementProps} (which carries event handlers).
356
+ *
357
+ * Idempotent — if every passed field equals the existing value, the call
358
+ * returns without mutating the entry or emitting an event. This keeps
359
+ * `useUIElement().updateLabel(...)` cheap when consumers re-publish from a
360
+ * render path that produces the same string on every tick.
361
+ *
362
+ * Returns `true` when the entry was mutated, `false` otherwise. Callers
363
+ * that want to gate a `console.warn` on no-op invocations can use the
364
+ * return value instead of recomputing equality.
365
+ */
366
+ updateElementMeta(id, meta) {
367
+ const element = this.elements.get(id);
368
+ if (!element) return false;
369
+ const currentIdentifier = element.getIdentifier();
370
+ const nextLabel = meta.label !== void 0 ? meta.label : element.label;
371
+ const nextAccessibilityLabel = meta.accessibilityLabel !== void 0 ? meta.accessibilityLabel : currentIdentifier.accessibilityLabel;
372
+ const nextTestId = meta.testId !== void 0 ? meta.testId : currentIdentifier.testId;
373
+ const labelChanged = nextLabel !== element.label;
374
+ const accessibilityLabelChanged = nextAccessibilityLabel !== currentIdentifier.accessibilityLabel;
375
+ const testIdChanged = nextTestId !== currentIdentifier.testId;
376
+ if (!labelChanged && !accessibilityLabelChanged && !testIdChanged) {
377
+ return false;
378
+ }
379
+ const updated = {
380
+ ...element,
381
+ label: nextLabel,
382
+ getIdentifier: () => ({
383
+ uiId: id,
384
+ testId: nextTestId,
385
+ accessibilityLabel: nextAccessibilityLabel,
386
+ treePath: currentIdentifier.treePath
387
+ })
388
+ };
389
+ this.elements.set(id, updated);
390
+ this.emit("element:registered", {
391
+ id,
392
+ type: element.type,
393
+ label: nextLabel
394
+ });
395
+ return true;
396
+ }
352
397
  /**
353
398
  * Update element props (for action execution)
354
399
  */
@@ -1781,6 +1826,245 @@ function createDesignHandlers(registry) {
1781
1826
  };
1782
1827
  }
1783
1828
 
1829
+ // src/server/page-health.ts
1830
+ var GRID_SIZE = 20;
1831
+ var NAV_TYPES = /* @__PURE__ */ new Set(["button", "heading", "badge", "status-message"]);
1832
+ var SKIP_TEXT_TYPES = /* @__PURE__ */ new Set(["button", "link", "tab", "menuitem"]);
1833
+ var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
1834
+ "button",
1835
+ "input",
1836
+ "switch",
1837
+ "checkbox",
1838
+ "radio",
1839
+ "touchable",
1840
+ "listItem"
1841
+ ]);
1842
+ var ERROR_PHRASES = [
1843
+ "error occurred",
1844
+ "failed to",
1845
+ "exception",
1846
+ "crash",
1847
+ "unavailable",
1848
+ "something went wrong",
1849
+ "could not"
1850
+ ];
1851
+ var LOADING_PHRASES = [
1852
+ "loading",
1853
+ "starting",
1854
+ "connecting",
1855
+ "please wait",
1856
+ "initializing",
1857
+ "fetching"
1858
+ ];
1859
+ var EMPTY_PHRASES = [
1860
+ "no data",
1861
+ "no results",
1862
+ "nothing here",
1863
+ "empty",
1864
+ "no items",
1865
+ "get started"
1866
+ ];
1867
+ function severityRank(s) {
1868
+ if (s === "CRITICAL") return 3;
1869
+ if (s === "WARNING") return 2;
1870
+ return 1;
1871
+ }
1872
+ function worstSeverity(findings) {
1873
+ let worst = "OK";
1874
+ for (const f of findings) {
1875
+ if (severityRank(f.severity) > severityRank(worst)) {
1876
+ worst = f.severity;
1877
+ }
1878
+ }
1879
+ return worst;
1880
+ }
1881
+ function truncate(s, n) {
1882
+ if (s.length <= n) return s;
1883
+ return [...s].slice(0, n).join("");
1884
+ }
1885
+ function normalizeRect(state, viewport) {
1886
+ const layout = state.layout;
1887
+ if (!layout) return null;
1888
+ if (viewport.width <= 0 || viewport.height <= 0) return null;
1889
+ const px = layout.pageX ?? layout.x;
1890
+ const py = layout.pageY ?? layout.y;
1891
+ return {
1892
+ x: px / viewport.width,
1893
+ y: py / viewport.height,
1894
+ width: layout.width / viewport.width,
1895
+ height: layout.height / viewport.height
1896
+ };
1897
+ }
1898
+ function diagnosePageHealth(elements, viewport) {
1899
+ const findings = [];
1900
+ const projected = elements.map((el) => ({
1901
+ el,
1902
+ rect: el.state.visible ? normalizeRect(el.state, viewport) : null
1903
+ }));
1904
+ const visible = projected.filter((p) => p.rect !== null);
1905
+ const grid = Array.from(
1906
+ { length: GRID_SIZE },
1907
+ () => Array.from({ length: GRID_SIZE }, () => false)
1908
+ );
1909
+ for (const { rect } of visible) {
1910
+ const colStart = Math.max(0, Math.floor(rect.x * GRID_SIZE));
1911
+ const colEnd = Math.min(GRID_SIZE, Math.ceil((rect.x + rect.width) * GRID_SIZE));
1912
+ const rowStart = Math.max(0, Math.floor(rect.y * GRID_SIZE));
1913
+ const rowEnd = Math.min(GRID_SIZE, Math.ceil((rect.y + rect.height) * GRID_SIZE));
1914
+ for (let r = rowStart; r < rowEnd; r++) {
1915
+ for (let c = colStart; c < colEnd; c++) {
1916
+ grid[r][c] = true;
1917
+ }
1918
+ }
1919
+ }
1920
+ const totalCells = GRID_SIZE * GRID_SIZE;
1921
+ const halfCells = totalCells / 2;
1922
+ let filled = 0;
1923
+ let leftFilled = 0;
1924
+ let rightFilled = 0;
1925
+ const half = GRID_SIZE / 2;
1926
+ for (let r = 0; r < GRID_SIZE; r++) {
1927
+ for (let c = 0; c < GRID_SIZE; c++) {
1928
+ if (grid[r][c]) {
1929
+ filled++;
1930
+ if (c < half) leftFilled++;
1931
+ else rightFilled++;
1932
+ }
1933
+ }
1934
+ }
1935
+ const coveragePct = Math.round(filled / totalCells * 100);
1936
+ const leftHalfPct = Math.round(leftFilled / halfCells * 100);
1937
+ const rightHalfPct = Math.round(rightFilled / halfCells * 100);
1938
+ let spatialSeverity = "OK";
1939
+ if (coveragePct < 15) spatialSeverity = "CRITICAL";
1940
+ else if (coveragePct < 30) spatialSeverity = "WARNING";
1941
+ if (rightHalfPct < 5 && leftHalfPct > 20) spatialSeverity = "CRITICAL";
1942
+ findings.push({
1943
+ check: "spatial_coverage",
1944
+ severity: spatialSeverity,
1945
+ detail: `Elements cover ${coveragePct}% of viewport. Left=${leftHalfPct}%, Right=${rightHalfPct}%`,
1946
+ data: {
1947
+ coverage_pct: coveragePct,
1948
+ left_half_pct: leftHalfPct,
1949
+ right_half_pct: rightHalfPct
1950
+ }
1951
+ });
1952
+ let sidebar = 0;
1953
+ let header = 0;
1954
+ let content = 0;
1955
+ for (const { rect } of visible) {
1956
+ const cx = rect.x + rect.width / 2;
1957
+ const cy = rect.y + rect.height / 2;
1958
+ if (cx < 0.2) sidebar++;
1959
+ else if (cy < 0.08) header++;
1960
+ else content++;
1961
+ }
1962
+ let layoutSeverity = "OK";
1963
+ if (content === 0) layoutSeverity = "CRITICAL";
1964
+ else if (content < 3) layoutSeverity = "WARNING";
1965
+ findings.push({
1966
+ check: "layout_regions",
1967
+ severity: layoutSeverity,
1968
+ detail: `sidebar=${sidebar}, header=${header}, content=${content}`,
1969
+ data: { sidebar, header, content }
1970
+ });
1971
+ const typeCounts = {};
1972
+ for (const el of elements) {
1973
+ const t = el.type || "unknown";
1974
+ typeCounts[t] = (typeCounts[t] ?? 0) + 1;
1975
+ }
1976
+ const allNav = elements.length > 5 && Object.keys(typeCounts).every((k) => NAV_TYPES.has(k));
1977
+ const diversitySeverity = allNav ? "WARNING" : "OK";
1978
+ findings.push({
1979
+ check: "element_diversity",
1980
+ severity: diversitySeverity,
1981
+ detail: `${Object.keys(typeCounts).length} type(s) across ${elements.length} elements${allNav ? " (navigation-only)" : ""}`,
1982
+ data: { types: typeCounts }
1983
+ });
1984
+ const detectedErrors = [];
1985
+ const detectedLoading = [];
1986
+ const detectedEmpty = [];
1987
+ const detectedCss = [];
1988
+ for (const el of elements) {
1989
+ const elType = el.type ?? "";
1990
+ if (SKIP_TEXT_TYPES.has(elType)) continue;
1991
+ const text = (el.state.textContent ?? "").toString();
1992
+ if (!text) continue;
1993
+ const textLower = text.toLowerCase();
1994
+ for (const p of ERROR_PHRASES) {
1995
+ if (textLower.includes(p)) {
1996
+ detectedErrors.push(truncate(text, 120));
1997
+ break;
1998
+ }
1999
+ }
2000
+ for (const p of LOADING_PHRASES) {
2001
+ if (textLower.includes(p)) {
2002
+ detectedLoading.push(truncate(text, 120));
2003
+ break;
2004
+ }
2005
+ }
2006
+ for (const p of EMPTY_PHRASES) {
2007
+ if (textLower.includes(p)) {
2008
+ detectedEmpty.push(truncate(text, 120));
2009
+ break;
2010
+ }
2011
+ }
2012
+ }
2013
+ let textSeverity = "OK";
2014
+ if (detectedErrors.length > 0) textSeverity = "CRITICAL";
2015
+ else if (detectedLoading.length > 0 || detectedEmpty.length > 0) textSeverity = "WARNING";
2016
+ findings.push({
2017
+ check: "text_signals",
2018
+ severity: textSeverity,
2019
+ detail: `errors=${detectedErrors.length}, loading=${detectedLoading.length}, empty=${detectedEmpty.length}, css_signals=${detectedCss.length}`,
2020
+ data: {
2021
+ errors: detectedErrors,
2022
+ loading: detectedLoading,
2023
+ empty: detectedEmpty,
2024
+ css_signals: detectedCss
2025
+ }
2026
+ });
2027
+ let interactiveTotal = 0;
2028
+ let interactiveDisabled = 0;
2029
+ for (const el of elements) {
2030
+ if (!INTERACTIVE_TYPES.has(el.type)) continue;
2031
+ interactiveTotal++;
2032
+ if (el.state.enabled === false) interactiveDisabled++;
2033
+ }
2034
+ const interactiveSeverity = interactiveTotal > 0 && interactiveDisabled / interactiveTotal > 0.5 ? "WARNING" : "OK";
2035
+ findings.push({
2036
+ check: "interactive_readiness",
2037
+ severity: interactiveSeverity,
2038
+ detail: `${interactiveTotal} interactive elements, ${interactiveDisabled} disabled`,
2039
+ data: { total: interactiveTotal, disabled: interactiveDisabled }
2040
+ });
2041
+ let zeroSize = 0;
2042
+ let outsideViewport = 0;
2043
+ for (const { rect } of visible) {
2044
+ if (rect.width === 0 || rect.height === 0) zeroSize++;
2045
+ if (rect.x + rect.width < 0 || rect.y + rect.height < 0 || rect.x > 1 || rect.y > 1) {
2046
+ outsideViewport++;
2047
+ }
2048
+ }
2049
+ const anomalySeverity = zeroSize > 0 || outsideViewport > 0 ? "WARNING" : "OK";
2050
+ findings.push({
2051
+ check: "visual_anomalies",
2052
+ severity: anomalySeverity,
2053
+ detail: `zero_size=${zeroSize}, outside_viewport=${outsideViewport}`,
2054
+ data: { zero_size: zeroSize, outside_viewport: outsideViewport }
2055
+ });
2056
+ const heatmap = grid.map(
2057
+ (row) => row.map((cell) => cell ? "#" : ".").join("")
2058
+ );
2059
+ return {
2060
+ summary: worstSeverity(findings),
2061
+ findings,
2062
+ heatmap,
2063
+ element_count: elements.length,
2064
+ visible_count: visible.length
2065
+ };
2066
+ }
2067
+
1784
2068
  // src/server/handlers.ts
1785
2069
  async function executeWorkflowStep(step, registry, executor) {
1786
2070
  const startTime = Date.now();
@@ -2165,6 +2449,25 @@ function createServerHandlers(registry, executor, config) {
2165
2449
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
2166
2450
  return success2(snapshot);
2167
2451
  },
2452
+ getPageHealth: async (ctx) => {
2453
+ const bodyViewport = ctx.body?.viewport;
2454
+ let viewport = bodyViewport;
2455
+ if (!viewport) {
2456
+ try {
2457
+ const { Dimensions: Dimensions2 } = __require("react-native");
2458
+ const win = Dimensions2.get("window");
2459
+ viewport = { width: win.width, height: win.height };
2460
+ } catch {
2461
+ viewport = { width: 0, height: 0 };
2462
+ }
2463
+ }
2464
+ const elements = registry.getAllElements().map((e) => ({
2465
+ type: e.type,
2466
+ state: e.getState()
2467
+ }));
2468
+ const report = diagnosePageHealth(elements, viewport);
2469
+ return success2(report);
2470
+ },
2168
2471
  // Workflows
2169
2472
  getWorkflows: async () => {
2170
2473
  const workflows = registry.getAllWorkflows().map((w) => ({
@@ -2913,6 +3216,10 @@ var WS_ROUTES = [
2913
3216
  { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
2914
3217
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
2915
3218
  { pattern: "control/discover", handler: "getSnapshot" },
3219
+ // Page health — accepts both GET and POST so the canonical skill invocation
3220
+ // (`POST .../control/page-health` with an empty body) and ad-hoc curls both
3221
+ // work without a body-shape gotcha.
3222
+ { pattern: "control/page-health", handler: "getPageHealth", httpMethods: ["GET", "POST"] },
2916
3223
  // Workflows
2917
3224
  { pattern: "control/workflows", handler: "getWorkflows", httpMethods: ["GET"] },
2918
3225
  { pattern: "control/workflow/:id/run", handler: "runWorkflow", httpMethods: ["POST"] },
@@ -3017,6 +3324,7 @@ var HANDLER_DESCRIPTIONS = {
3017
3324
  executeComponentAction: "Invoke a component action directly (preferred over button clicks)",
3018
3325
  find: "AI-powered element search by natural-language query",
3019
3326
  getSnapshot: "Full snapshot: elements + route + state. Supports ?visibleOnly=true and ?currentRouteOnly=true",
3327
+ getPageHealth: 'Holistic page health diagnostic over current snapshot elements (spatial coverage, layout regions, text/anomaly signals, heatmap). Optional body: { viewport?: { width, height } } to override Dimensions.get("window").',
3020
3328
  getWorkflows: "List registered workflows",
3021
3329
  runWorkflow: "Trigger a workflow by id",
3022
3330
  pageRefresh: "Reload the current route",
@@ -5164,6 +5472,7 @@ function flattenStateStyles(stateStyles) {
5164
5472
  return Object.keys(result).length > 0 ? result : void 0;
5165
5473
  }
5166
5474
  var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5475
+ var warnedRedundantUpdateLabelIds = /* @__PURE__ */ new Set();
5167
5476
  function scheduleDeadButtonWarning(bridge, id, type) {
5168
5477
  if (type !== "button" && type !== "pressable") return;
5169
5478
  if (warnedDeadButtonIds.has(id)) return;
@@ -5392,6 +5701,23 @@ function useUIElement(options) {
5392
5701
  },
5393
5702
  [bridge, id]
5394
5703
  );
5704
+ const updateLabel = useCallback(
5705
+ (newLabel) => {
5706
+ if (!bridge || !registered) return;
5707
+ const changed = bridge.registry.updateElementMeta(id, {
5708
+ label: newLabel,
5709
+ accessibilityLabel: newLabel,
5710
+ testId: id
5711
+ });
5712
+ if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
5713
+ warnedRedundantUpdateLabelIds.add(id);
5714
+ console.warn(
5715
+ `[UI Bridge] updateLabel("${newLabel}") on element "${id}" was a no-op (label unchanged). Gate the call behind a useEffect with [label] deps to avoid redundant publishes.`
5716
+ );
5717
+ }
5718
+ },
5719
+ [bridge, registered, id]
5720
+ );
5395
5721
  const updateStyle = useCallback(
5396
5722
  (newStyle, newStateStyles) => {
5397
5723
  if (!bridge || !registered) return;
@@ -5417,7 +5743,8 @@ function useUIElement(options) {
5417
5743
  register,
5418
5744
  unregister,
5419
5745
  registeredElement,
5420
- updateStyle
5746
+ updateStyle,
5747
+ updateLabel
5421
5748
  };
5422
5749
  }
5423
5750
  function useUIElementWithProps(options) {
@@ -5731,6 +6058,11 @@ var UI_BRIDGE_NATIVE_ROUTES = {
5731
6058
  path: "/ui-bridge/control/snapshot",
5732
6059
  description: "Get full bridge snapshot"
5733
6060
  },
6061
+ PAGE_HEALTH: {
6062
+ method: "POST",
6063
+ path: "/ui-bridge/control/page-health",
6064
+ description: "Holistic page health diagnostic: spatial coverage, layout regions, text signals, interactive readiness, anomalies + ASCII heatmap"
6065
+ },
5734
6066
  // Workflows
5735
6067
  GET_WORKFLOWS: {
5736
6068
  method: "GET",