@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.
@@ -351,6 +351,51 @@ var NativeUIBridgeRegistry = class {
351
351
  this.emit("element:stateChanged", { id, state: newState });
352
352
  }
353
353
  }
354
+ /**
355
+ * Update an element's descriptive metadata (label / accessibilityLabel /
356
+ * testId). Sister to {@link updateElementState} (which carries layout +
357
+ * visibility) and {@link updateElementProps} (which carries event handlers).
358
+ *
359
+ * Idempotent — if every passed field equals the existing value, the call
360
+ * returns without mutating the entry or emitting an event. This keeps
361
+ * `useUIElement().updateLabel(...)` cheap when consumers re-publish from a
362
+ * render path that produces the same string on every tick.
363
+ *
364
+ * Returns `true` when the entry was mutated, `false` otherwise. Callers
365
+ * that want to gate a `console.warn` on no-op invocations can use the
366
+ * return value instead of recomputing equality.
367
+ */
368
+ updateElementMeta(id, meta) {
369
+ const element = this.elements.get(id);
370
+ if (!element) return false;
371
+ const currentIdentifier = element.getIdentifier();
372
+ const nextLabel = meta.label !== void 0 ? meta.label : element.label;
373
+ const nextAccessibilityLabel = meta.accessibilityLabel !== void 0 ? meta.accessibilityLabel : currentIdentifier.accessibilityLabel;
374
+ const nextTestId = meta.testId !== void 0 ? meta.testId : currentIdentifier.testId;
375
+ const labelChanged = nextLabel !== element.label;
376
+ const accessibilityLabelChanged = nextAccessibilityLabel !== currentIdentifier.accessibilityLabel;
377
+ const testIdChanged = nextTestId !== currentIdentifier.testId;
378
+ if (!labelChanged && !accessibilityLabelChanged && !testIdChanged) {
379
+ return false;
380
+ }
381
+ const updated = {
382
+ ...element,
383
+ label: nextLabel,
384
+ getIdentifier: () => ({
385
+ uiId: id,
386
+ testId: nextTestId,
387
+ accessibilityLabel: nextAccessibilityLabel,
388
+ treePath: currentIdentifier.treePath
389
+ })
390
+ };
391
+ this.elements.set(id, updated);
392
+ this.emit("element:registered", {
393
+ id,
394
+ type: element.type,
395
+ label: nextLabel
396
+ });
397
+ return true;
398
+ }
354
399
  /**
355
400
  * Update element props (for action execution)
356
401
  */
@@ -1731,6 +1776,245 @@ function createDesignHandlers(registry) {
1731
1776
  };
1732
1777
  }
1733
1778
 
1779
+ // src/server/page-health.ts
1780
+ var GRID_SIZE = 20;
1781
+ var NAV_TYPES = /* @__PURE__ */ new Set(["button", "heading", "badge", "status-message"]);
1782
+ var SKIP_TEXT_TYPES = /* @__PURE__ */ new Set(["button", "link", "tab", "menuitem"]);
1783
+ var INTERACTIVE_TYPES = /* @__PURE__ */ new Set([
1784
+ "button",
1785
+ "input",
1786
+ "switch",
1787
+ "checkbox",
1788
+ "radio",
1789
+ "touchable",
1790
+ "listItem"
1791
+ ]);
1792
+ var ERROR_PHRASES = [
1793
+ "error occurred",
1794
+ "failed to",
1795
+ "exception",
1796
+ "crash",
1797
+ "unavailable",
1798
+ "something went wrong",
1799
+ "could not"
1800
+ ];
1801
+ var LOADING_PHRASES = [
1802
+ "loading",
1803
+ "starting",
1804
+ "connecting",
1805
+ "please wait",
1806
+ "initializing",
1807
+ "fetching"
1808
+ ];
1809
+ var EMPTY_PHRASES = [
1810
+ "no data",
1811
+ "no results",
1812
+ "nothing here",
1813
+ "empty",
1814
+ "no items",
1815
+ "get started"
1816
+ ];
1817
+ function severityRank(s) {
1818
+ if (s === "CRITICAL") return 3;
1819
+ if (s === "WARNING") return 2;
1820
+ return 1;
1821
+ }
1822
+ function worstSeverity(findings) {
1823
+ let worst = "OK";
1824
+ for (const f of findings) {
1825
+ if (severityRank(f.severity) > severityRank(worst)) {
1826
+ worst = f.severity;
1827
+ }
1828
+ }
1829
+ return worst;
1830
+ }
1831
+ function truncate(s, n) {
1832
+ if (s.length <= n) return s;
1833
+ return [...s].slice(0, n).join("");
1834
+ }
1835
+ function normalizeRect(state, viewport) {
1836
+ const layout = state.layout;
1837
+ if (!layout) return null;
1838
+ if (viewport.width <= 0 || viewport.height <= 0) return null;
1839
+ const px = layout.pageX ?? layout.x;
1840
+ const py = layout.pageY ?? layout.y;
1841
+ return {
1842
+ x: px / viewport.width,
1843
+ y: py / viewport.height,
1844
+ width: layout.width / viewport.width,
1845
+ height: layout.height / viewport.height
1846
+ };
1847
+ }
1848
+ function diagnosePageHealth(elements, viewport) {
1849
+ const findings = [];
1850
+ const projected = elements.map((el) => ({
1851
+ el,
1852
+ rect: el.state.visible ? normalizeRect(el.state, viewport) : null
1853
+ }));
1854
+ const visible = projected.filter((p) => p.rect !== null);
1855
+ const grid = Array.from(
1856
+ { length: GRID_SIZE },
1857
+ () => Array.from({ length: GRID_SIZE }, () => false)
1858
+ );
1859
+ for (const { rect } of visible) {
1860
+ const colStart = Math.max(0, Math.floor(rect.x * GRID_SIZE));
1861
+ const colEnd = Math.min(GRID_SIZE, Math.ceil((rect.x + rect.width) * GRID_SIZE));
1862
+ const rowStart = Math.max(0, Math.floor(rect.y * GRID_SIZE));
1863
+ const rowEnd = Math.min(GRID_SIZE, Math.ceil((rect.y + rect.height) * GRID_SIZE));
1864
+ for (let r = rowStart; r < rowEnd; r++) {
1865
+ for (let c = colStart; c < colEnd; c++) {
1866
+ grid[r][c] = true;
1867
+ }
1868
+ }
1869
+ }
1870
+ const totalCells = GRID_SIZE * GRID_SIZE;
1871
+ const halfCells = totalCells / 2;
1872
+ let filled = 0;
1873
+ let leftFilled = 0;
1874
+ let rightFilled = 0;
1875
+ const half = GRID_SIZE / 2;
1876
+ for (let r = 0; r < GRID_SIZE; r++) {
1877
+ for (let c = 0; c < GRID_SIZE; c++) {
1878
+ if (grid[r][c]) {
1879
+ filled++;
1880
+ if (c < half) leftFilled++;
1881
+ else rightFilled++;
1882
+ }
1883
+ }
1884
+ }
1885
+ const coveragePct = Math.round(filled / totalCells * 100);
1886
+ const leftHalfPct = Math.round(leftFilled / halfCells * 100);
1887
+ const rightHalfPct = Math.round(rightFilled / halfCells * 100);
1888
+ let spatialSeverity = "OK";
1889
+ if (coveragePct < 15) spatialSeverity = "CRITICAL";
1890
+ else if (coveragePct < 30) spatialSeverity = "WARNING";
1891
+ if (rightHalfPct < 5 && leftHalfPct > 20) spatialSeverity = "CRITICAL";
1892
+ findings.push({
1893
+ check: "spatial_coverage",
1894
+ severity: spatialSeverity,
1895
+ detail: `Elements cover ${coveragePct}% of viewport. Left=${leftHalfPct}%, Right=${rightHalfPct}%`,
1896
+ data: {
1897
+ coverage_pct: coveragePct,
1898
+ left_half_pct: leftHalfPct,
1899
+ right_half_pct: rightHalfPct
1900
+ }
1901
+ });
1902
+ let sidebar = 0;
1903
+ let header = 0;
1904
+ let content = 0;
1905
+ for (const { rect } of visible) {
1906
+ const cx = rect.x + rect.width / 2;
1907
+ const cy = rect.y + rect.height / 2;
1908
+ if (cx < 0.2) sidebar++;
1909
+ else if (cy < 0.08) header++;
1910
+ else content++;
1911
+ }
1912
+ let layoutSeverity = "OK";
1913
+ if (content === 0) layoutSeverity = "CRITICAL";
1914
+ else if (content < 3) layoutSeverity = "WARNING";
1915
+ findings.push({
1916
+ check: "layout_regions",
1917
+ severity: layoutSeverity,
1918
+ detail: `sidebar=${sidebar}, header=${header}, content=${content}`,
1919
+ data: { sidebar, header, content }
1920
+ });
1921
+ const typeCounts = {};
1922
+ for (const el of elements) {
1923
+ const t = el.type || "unknown";
1924
+ typeCounts[t] = (typeCounts[t] ?? 0) + 1;
1925
+ }
1926
+ const allNav = elements.length > 5 && Object.keys(typeCounts).every((k) => NAV_TYPES.has(k));
1927
+ const diversitySeverity = allNav ? "WARNING" : "OK";
1928
+ findings.push({
1929
+ check: "element_diversity",
1930
+ severity: diversitySeverity,
1931
+ detail: `${Object.keys(typeCounts).length} type(s) across ${elements.length} elements${allNav ? " (navigation-only)" : ""}`,
1932
+ data: { types: typeCounts }
1933
+ });
1934
+ const detectedErrors = [];
1935
+ const detectedLoading = [];
1936
+ const detectedEmpty = [];
1937
+ const detectedCss = [];
1938
+ for (const el of elements) {
1939
+ const elType = el.type ?? "";
1940
+ if (SKIP_TEXT_TYPES.has(elType)) continue;
1941
+ const text = (el.state.textContent ?? "").toString();
1942
+ if (!text) continue;
1943
+ const textLower = text.toLowerCase();
1944
+ for (const p of ERROR_PHRASES) {
1945
+ if (textLower.includes(p)) {
1946
+ detectedErrors.push(truncate(text, 120));
1947
+ break;
1948
+ }
1949
+ }
1950
+ for (const p of LOADING_PHRASES) {
1951
+ if (textLower.includes(p)) {
1952
+ detectedLoading.push(truncate(text, 120));
1953
+ break;
1954
+ }
1955
+ }
1956
+ for (const p of EMPTY_PHRASES) {
1957
+ if (textLower.includes(p)) {
1958
+ detectedEmpty.push(truncate(text, 120));
1959
+ break;
1960
+ }
1961
+ }
1962
+ }
1963
+ let textSeverity = "OK";
1964
+ if (detectedErrors.length > 0) textSeverity = "CRITICAL";
1965
+ else if (detectedLoading.length > 0 || detectedEmpty.length > 0) textSeverity = "WARNING";
1966
+ findings.push({
1967
+ check: "text_signals",
1968
+ severity: textSeverity,
1969
+ detail: `errors=${detectedErrors.length}, loading=${detectedLoading.length}, empty=${detectedEmpty.length}, css_signals=${detectedCss.length}`,
1970
+ data: {
1971
+ errors: detectedErrors,
1972
+ loading: detectedLoading,
1973
+ empty: detectedEmpty,
1974
+ css_signals: detectedCss
1975
+ }
1976
+ });
1977
+ let interactiveTotal = 0;
1978
+ let interactiveDisabled = 0;
1979
+ for (const el of elements) {
1980
+ if (!INTERACTIVE_TYPES.has(el.type)) continue;
1981
+ interactiveTotal++;
1982
+ if (el.state.enabled === false) interactiveDisabled++;
1983
+ }
1984
+ const interactiveSeverity = interactiveTotal > 0 && interactiveDisabled / interactiveTotal > 0.5 ? "WARNING" : "OK";
1985
+ findings.push({
1986
+ check: "interactive_readiness",
1987
+ severity: interactiveSeverity,
1988
+ detail: `${interactiveTotal} interactive elements, ${interactiveDisabled} disabled`,
1989
+ data: { total: interactiveTotal, disabled: interactiveDisabled }
1990
+ });
1991
+ let zeroSize = 0;
1992
+ let outsideViewport = 0;
1993
+ for (const { rect } of visible) {
1994
+ if (rect.width === 0 || rect.height === 0) zeroSize++;
1995
+ if (rect.x + rect.width < 0 || rect.y + rect.height < 0 || rect.x > 1 || rect.y > 1) {
1996
+ outsideViewport++;
1997
+ }
1998
+ }
1999
+ const anomalySeverity = zeroSize > 0 || outsideViewport > 0 ? "WARNING" : "OK";
2000
+ findings.push({
2001
+ check: "visual_anomalies",
2002
+ severity: anomalySeverity,
2003
+ detail: `zero_size=${zeroSize}, outside_viewport=${outsideViewport}`,
2004
+ data: { zero_size: zeroSize, outside_viewport: outsideViewport }
2005
+ });
2006
+ const heatmap = grid.map(
2007
+ (row) => row.map((cell) => cell ? "#" : ".").join("")
2008
+ );
2009
+ return {
2010
+ summary: worstSeverity(findings),
2011
+ findings,
2012
+ heatmap,
2013
+ element_count: elements.length,
2014
+ visible_count: visible.length
2015
+ };
2016
+ }
2017
+
1734
2018
  // src/server/handlers.ts
1735
2019
  async function executeWorkflowStep(step, registry, executor) {
1736
2020
  const startTime = Date.now();
@@ -2115,6 +2399,25 @@ function createServerHandlers(registry, executor, config) {
2115
2399
  const snapshot = registry.createSnapshot(void 0, { visibleOnly, currentRouteOnly });
2116
2400
  return success2(snapshot);
2117
2401
  },
2402
+ getPageHealth: async (ctx) => {
2403
+ const bodyViewport = ctx.body?.viewport;
2404
+ let viewport = bodyViewport;
2405
+ if (!viewport) {
2406
+ try {
2407
+ const { Dimensions } = __require("react-native");
2408
+ const win = Dimensions.get("window");
2409
+ viewport = { width: win.width, height: win.height };
2410
+ } catch {
2411
+ viewport = { width: 0, height: 0 };
2412
+ }
2413
+ }
2414
+ const elements = registry.getAllElements().map((e) => ({
2415
+ type: e.type,
2416
+ state: e.getState()
2417
+ }));
2418
+ const report = diagnosePageHealth(elements, viewport);
2419
+ return success2(report);
2420
+ },
2118
2421
  // Workflows
2119
2422
  getWorkflows: async () => {
2120
2423
  const workflows = registry.getAllWorkflows().map((w) => ({
@@ -2863,6 +3166,10 @@ var WS_ROUTES = [
2863
3166
  { pattern: "ai/find", handler: "find", httpMethods: ["POST"] },
2864
3167
  { pattern: "control/snapshot", handler: "getSnapshot", httpMethods: ["GET"] },
2865
3168
  { pattern: "control/discover", handler: "getSnapshot" },
3169
+ // Page health — accepts both GET and POST so the canonical skill invocation
3170
+ // (`POST .../control/page-health` with an empty body) and ad-hoc curls both
3171
+ // work without a body-shape gotcha.
3172
+ { pattern: "control/page-health", handler: "getPageHealth", httpMethods: ["GET", "POST"] },
2866
3173
  // Workflows
2867
3174
  { pattern: "control/workflows", handler: "getWorkflows", httpMethods: ["GET"] },
2868
3175
  { pattern: "control/workflow/:id/run", handler: "runWorkflow", httpMethods: ["POST"] },
@@ -2967,6 +3274,7 @@ var HANDLER_DESCRIPTIONS = {
2967
3274
  executeComponentAction: "Invoke a component action directly (preferred over button clicks)",
2968
3275
  find: "AI-powered element search by natural-language query",
2969
3276
  getSnapshot: "Full snapshot: elements + route + state. Supports ?visibleOnly=true and ?currentRouteOnly=true",
3277
+ 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").',
2970
3278
  getWorkflows: "List registered workflows",
2971
3279
  runWorkflow: "Trigger a workflow by id",
2972
3280
  pageRefresh: "Reload the current route",
@@ -5114,6 +5422,7 @@ function flattenStateStyles(stateStyles) {
5114
5422
  return Object.keys(result).length > 0 ? result : void 0;
5115
5423
  }
5116
5424
  var warnedDeadButtonIds = /* @__PURE__ */ new Set();
5425
+ var warnedRedundantUpdateLabelIds = /* @__PURE__ */ new Set();
5117
5426
  function scheduleDeadButtonWarning(bridge, id, type) {
5118
5427
  if (type !== "button" && type !== "pressable") return;
5119
5428
  if (warnedDeadButtonIds.has(id)) return;
@@ -5342,6 +5651,23 @@ function useUIElement(options) {
5342
5651
  },
5343
5652
  [bridge, id]
5344
5653
  );
5654
+ const updateLabel = react.useCallback(
5655
+ (newLabel) => {
5656
+ if (!bridge || !registered) return;
5657
+ const changed = bridge.registry.updateElementMeta(id, {
5658
+ label: newLabel,
5659
+ accessibilityLabel: newLabel,
5660
+ testId: id
5661
+ });
5662
+ if (!changed && !warnedRedundantUpdateLabelIds.has(id)) {
5663
+ warnedRedundantUpdateLabelIds.add(id);
5664
+ console.warn(
5665
+ `[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.`
5666
+ );
5667
+ }
5668
+ },
5669
+ [bridge, registered, id]
5670
+ );
5345
5671
  const updateStyle = react.useCallback(
5346
5672
  (newStyle, newStateStyles) => {
5347
5673
  if (!bridge || !registered) return;
@@ -5367,7 +5693,8 @@ function useUIElement(options) {
5367
5693
  register,
5368
5694
  unregister,
5369
5695
  registeredElement,
5370
- updateStyle
5696
+ updateStyle,
5697
+ updateLabel
5371
5698
  };
5372
5699
  }
5373
5700
  function useUIElementWithProps(options) {