<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Wuic Framework</title>
    <description>The latest articles on DEV Community by Wuic Framework (@wuicframework).</description>
    <link>https://dev.to/wuicframework</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3961274%2F1a2b53ce-ceb3-4457-bb5d-81eaf4810e1a.png</url>
      <title>DEV Community: Wuic Framework</title>
      <link>https://dev.to/wuicframework</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/wuicframework"/>
    <language>en</language>
    <item>
      <title>Mobile-first auto-layout with zero config: how WUIC reflows enterprise tables on a phone</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:21:35 +0000</pubDate>
      <link>https://dev.to/wuicframework/mobile-first-auto-layout-with-zero-config-how-wuic-reflows-enterprise-tables-on-a-phone-56lg</link>
      <guid>https://dev.to/wuicframework/mobile-first-auto-layout-with-zero-config-how-wuic-reflows-enterprise-tables-on-a-phone-56lg</guid>
      <description>&lt;p&gt;A complaint that comes up every time we demo WUIC to someone with a CRM or back-office app: &lt;em&gt;"this is all very nice on a 27-inch monitor, but our reps live on iPads in the field, and your default list view is unreadable on a phone."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It used to be a fair complaint. The fix wasn't dramatic — a modest diff across two components, some SCSS, and one small service. But what we got out of it is &lt;strong&gt;zero per-screen configuration for mobile&lt;/strong&gt;: every existing scaffolded route automatically gets a card-stack layout on phones and a vertically stacked edit form, and the dev doesn't write a single media query.&lt;/p&gt;

&lt;p&gt;This post walks through how it's wired, and where it draws the line.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default behavior
&lt;/h2&gt;

&lt;p&gt;Open any list page on the &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt; at 1920×1080 and you see a &lt;code&gt;&amp;lt;p-table&amp;gt;&lt;/code&gt; from PrimeNG: dense, columnar, scrollable, with sort/filter/group headers. The classic enterprise table.&lt;/p&gt;

&lt;p&gt;Now resize the window below 768px (or open it on a phone). The table layout disappears, and in its place is a vertical stack of cards — one per record. Each card shows the action buttons on top and a label/value row for every column that's visible in the list (the same &lt;code&gt;mc_hide_in_list&lt;/code&gt; rules the desktop grid uses), with the same cell formatting: lookups resolve, uploads render thumbnails, colors render swatches.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6ednbymfe2eeagrshuv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6ednbymfe2eeagrshuv.png" alt="List-grid on a phone — one card per record, action buttons on top, label/value rows for every visible column" width="800" height="380"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;No metadata change. No &lt;code&gt;if (mobile) { ... }&lt;/code&gt; in your app code. Same route, same datasource, same metadata — the framework swaps the row template.&lt;/p&gt;

&lt;p&gt;The edit form (&lt;code&gt;&amp;lt;wuic-parametric-dialog&amp;gt;&lt;/code&gt;) reflows too: multi-column layouts collapse to a single column, fields go full-width, the tab list becomes horizontally scrollable, and the toolbar compresses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the swap lives
&lt;/h2&gt;

&lt;p&gt;There are two distinct mechanisms because they solve different problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  List-grid: runtime row-template swap (TS)
&lt;/h3&gt;

&lt;p&gt;The list-grid renders one PrimeNG &lt;code&gt;&amp;lt;p-table&amp;gt;&lt;/code&gt; on both viewports — but the &lt;strong&gt;row template&lt;/strong&gt;, which is compiled at runtime anyway (that's how per-route custom row templates work), is different on mobile:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// list-grid.component.ts (excerpt)&lt;/span&gt;
&lt;span class="k"&gt;private&lt;/span&gt; &lt;span class="nf"&gt;getEffectiveGridRowTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metaInfo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;MetaInfo&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Mobile: the row template emits a single &amp;lt;td colspan&amp;gt; with a&lt;/span&gt;
  &lt;span class="c1"&gt;// card-style layout — same &amp;lt;p-table&amp;gt;, same JIT compile pipeline.&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deviceAwareness&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;isMobile&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;buildMobileCardAsRowTemplate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metaInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;columnMetadata&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;customRowTemplate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;metaInfo&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;tableMetadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;md_rowTemplate&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customRowTemplate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;customRowTemplate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// ... host override, then library default&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On mobile the compiled row is a single &lt;code&gt;&amp;lt;td colspan&amp;gt;&lt;/code&gt; containing the card markup; a &lt;code&gt;wuic-list-grid-mobile&lt;/code&gt; CSS class plus a media query hide the &lt;code&gt;&amp;lt;thead&amp;gt;&lt;/code&gt; and turn &lt;code&gt;&amp;lt;tr&amp;gt;&lt;/code&gt;/&lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt; into &lt;code&gt;display: block&lt;/code&gt;, freeing the DOM from table semantics. The template string is compiled into a real component class at runtime via Angular's &lt;code&gt;ɵcompileComponent&lt;/code&gt; — the same pipeline that powers custom row templates on desktop.&lt;/p&gt;

&lt;p&gt;The viewport detection is a singleton service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nd"&gt;Injectable&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;providedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;root&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeviceAwarenessService&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;readonly&lt;/span&gt; &lt;span class="nx"&gt;isMobile$&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Observable&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isMobileSubject&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;distinctUntilChanged&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="nf"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;breakpointPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resolveBreakpointPx&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// widgetDefinition, default 768&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mediaQueryList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;matchMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`(max-width: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;breakpointPx&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;px)`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ...listener pushes matches into the BehaviorSubject&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the viewport crosses the breakpoint, the observable emits, the grid recompiles its row template, and the same &lt;code&gt;&amp;lt;p-table&amp;gt;&lt;/code&gt; re-renders as cards. No reload, no double-fetch — the data is already in the component's &lt;code&gt;records&lt;/code&gt; array.&lt;/p&gt;

&lt;h3&gt;
  
  
  Customizing the card: &lt;code&gt;mobileCardTemplate&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The default card is built for you — an action-button row on top, then one label/value row per visible column, reusing the exact same cell renderers as the desktop grid (a lookup link, an image thumbnail, a colour swatch all render the way they do in a table cell). For most back-office screens that's the whole job.&lt;/p&gt;

&lt;p&gt;When you want a different card layout, the public &lt;code&gt;WidgetDefinition&lt;/code&gt; API exposes a &lt;code&gt;mobileCardTemplate&lt;/code&gt; hook — an Angular template string that replaces the card body. It's compiled at runtime through the same &lt;code&gt;ɵcompileComponent&lt;/code&gt; pipeline as the row template and injected into the card cell, so inside it you have the grid's scope: &lt;code&gt;rowData&lt;/code&gt; for the current record, the &lt;code&gt;columns&lt;/code&gt; list, &lt;code&gt;metaInfo&lt;/code&gt;, and the same helper pipes the default uses (&lt;code&gt;formatGridViewValue&lt;/code&gt;, &lt;code&gt;visibleFieldList&lt;/code&gt;, the cell-class helpers). One thing to know up front: it's set at the host level, so it applies to every grid in the app — it's a global card style, not a per-route override.&lt;/p&gt;

&lt;p&gt;The full list of variables available in the template's scope is in the &lt;a href="https://wuic-framework.com/docs/responsive-mobile-layout" rel="noopener noreferrer"&gt;responsive-layout docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Edit-form: CSS-only reflow (SCSS)
&lt;/h3&gt;

&lt;p&gt;The edit form is the opposite call. &lt;code&gt;&amp;lt;wuic-parametric-dialog&amp;gt;&lt;/code&gt; already builds a flexbox row layout in plain HTML — there's no library DOM fighting us, and the mobile layout wants the &lt;em&gt;same&lt;/em&gt; DOM, just stacked. So no template swap; a media query does it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight scss"&gt;&lt;code&gt;&lt;span class="c1"&gt;// parametric-dialog.component.scss&lt;/span&gt;
&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max-width&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="m"&gt;768px&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.row&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;flex-direction&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;column&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;flex-wrap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;nowrap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// default .row wraps → phantom second column&lt;/span&gt;
    &lt;span class="na"&gt;gap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;12px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nd"&gt;:host&lt;/span&gt; &lt;span class="nd"&gt;::ng-deep&lt;/span&gt; &lt;span class="nc"&gt;.data-field-wrapper&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;max-width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nd"&gt;:host&lt;/span&gt; &lt;span class="nd"&gt;::ng-deep&lt;/span&gt; &lt;span class="nc"&gt;.data-field-field&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;important&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;!important&lt;/code&gt; is defensive: field widths come from &lt;code&gt;mc_ui_size_width&lt;/code&gt; per-column (a designer-saved value rendered as inline styles) and the media query has to win on small screens. The same defensive rule lives in &lt;code&gt;field-editor.component.scss&lt;/code&gt; so it applies whether the field is hosted directly or inside a wrapper.&lt;/p&gt;

&lt;p&gt;Total mobile-specific code for the edit form: a few dozen lines of SCSS (stacking, tab-list scroll, compact toolbar). No TypeScript.&lt;/p&gt;

&lt;p&gt;That covers the automatic reflow. When a screen needs a genuinely bespoke form — grouped panels, a custom header, fields laid out around an image — the edit dialog takes a full custom template too: set &lt;code&gt;md_edit_template&lt;/code&gt; on the route and &lt;code&gt;&amp;lt;wuic-parametric-dialog&amp;gt;&lt;/code&gt; renders your Angular form markup instead of the auto-generated field layout, compiled at runtime. Unlike the list's &lt;code&gt;mobileCardTemplate&lt;/code&gt; (which is host-wide), this one is &lt;strong&gt;per-route&lt;/strong&gt;, so each screen opts in independently; &lt;code&gt;md_detail_template&lt;/code&gt; does the same for the read-only detail view. You own that markup — including how it reflows on a phone — so the automatic stacking above applies to the default layout, not to a template you hand-write.&lt;/p&gt;

&lt;p&gt;Why the asymmetry? Because the list needs &lt;em&gt;different DOM&lt;/em&gt; per viewport — cells versus cards — which is a template problem, while the form needs the &lt;em&gt;same DOM in a different flow&lt;/em&gt; — which is a CSS problem. The right answer was different for each.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we don't try to handle automatically
&lt;/h2&gt;

&lt;p&gt;We've seen "responsive mode" sold as a magic checkbox, and we don't want to over-promise. Here's the explicit list of what stays manual:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-column priority on the card.&lt;/strong&gt; The default card shows every column that's visible in the list. If you want "name + city + last_seen on the card, hide the rest", you hide columns via the existing &lt;code&gt;mc_hide_in_list&lt;/code&gt; rules and condition system. We don't have a dedicated &lt;code&gt;mobile_visible&lt;/code&gt; flag — adding a second flag for every responsive concern is how metadata schemas balloon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inline editing on mobile.&lt;/strong&gt; Deliberately unsupported: tapping a card opens the edit form. Cell-by-cell editing on a 380px viewport is a UX trap we chose not to fall into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter bar.&lt;/strong&gt; On desktop it's an inline collapsible panel above the list. On mobile that's unusable (it ate most of the viewport in early prototypes) — so below the breakpoint the same content opens as a full-screen overlay with its own header and close button, and the list underneath stays intact. That behavior is hard-coded in the filter-bar component, not metadata; if you don't like it, you override the component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Designer surfaces.&lt;/strong&gt; Drag-and-drop designers (dashboard, report, workflow) are desktop tools; we don't attempt to make them mobile-friendly. Read-mode dashboards reflow fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom Angular components.&lt;/strong&gt; If you wrote a custom widget (a colour picker, a map, a charting widget) and didn't think about mobile, it'll look bad on mobile. The framework doesn't know how to reflow third-party components — that's on you. &lt;code&gt;DeviceAwarenessService.isMobile$&lt;/code&gt; is public precisely so your components can make the same call the grid makes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The breakpoint
&lt;/h2&gt;

&lt;p&gt;The default is 768px, matching the Bootstrap-style convention (and the framework's own &lt;code&gt;@media&lt;/code&gt; rules). Override per app at bootstrap — &lt;code&gt;widgetDefinition&lt;/code&gt; is a static:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// main.ts (or an APP_INITIALIZER)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;MetadataProviderService&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;wuic-framework-lib&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./environments/environment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;MetadataProviderService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;widgetDefinition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mobileBreakpointPx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mobileBreakpointPx&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;768&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The breakpoint is read &lt;strong&gt;once&lt;/strong&gt;, at &lt;code&gt;DeviceAwarenessService&lt;/code&gt; construction — changing it later means re-bootstrapping the app, which in practice nobody needs. The real use cases are static per app: "this app is tablet-first, raise it to 1024" or "this app runs on warehouse scanners, lower it to 600".&lt;/p&gt;

&lt;h2&gt;
  
  
  Performance corner
&lt;/h2&gt;

&lt;p&gt;Two things worth mentioning, both consequences of the unification:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Virtualization comes for free.&lt;/strong&gt; Card stacks can have hundreds of rows, and the first prototype rendered its separate card list entirely into the DOM — fine for 50 cards, smelly for 500. Since mobile now renders through the same &lt;code&gt;&amp;lt;p-table&amp;gt;&lt;/code&gt;, the grid's virtual scrolling — gated by the same &lt;code&gt;isListVirtualizationEnabled()&lt;/code&gt; switch as desktop — applies to the cards too. One flag, both viewports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No churn on resize.&lt;/strong&gt; &lt;code&gt;isMobile$&lt;/code&gt; goes through &lt;code&gt;distinctUntilChanged&lt;/code&gt;, so the row template recompiles only on the actual boolean transition across the breakpoint, not on every resize tick.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's not enough
&lt;/h2&gt;

&lt;p&gt;If you need a layout that's neither a desktop table nor a vertical card stack — say, a kanban board on phone, or a horizontally-paginated card carousel — the right answer is a different archetype, not a flag. &lt;code&gt;kanban&lt;/code&gt; and &lt;code&gt;carousel&lt;/code&gt; are archetypes in the framework's widget definition (alongside &lt;code&gt;scheduler&lt;/code&gt;, map, tree and the rest), and each renders on both viewports with its own behavior.&lt;/p&gt;

&lt;p&gt;Mobile auto-layout in WUIC handles the &lt;em&gt;transactional&lt;/em&gt; archetypes (list + edit form), which is 80% of an enterprise back-office. The 20% — kanban, scheduler, map — has its own per-archetype responsive story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The public &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;demo&lt;/a&gt; runs in any browser at any width. Open DevTools, switch to a phone profile, navigate to any list — you'll see the card swap live. The credentials are on the &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;sandbox landing page&lt;/a&gt;; login takes 2 seconds.&lt;/p&gt;

&lt;p&gt;The full list of files involved (for the curious): &lt;code&gt;device-awareness.service.ts&lt;/code&gt;, &lt;code&gt;list-grid.component.{html,ts,scss}&lt;/code&gt;, &lt;code&gt;filter-bar.component.{html,ts}&lt;/code&gt;, &lt;code&gt;parametric-dialog.component.scss&lt;/code&gt;, &lt;code&gt;field-editor.component.scss&lt;/code&gt; — plus the &lt;code&gt;widgetDefinition.mobileBreakpointPx&lt;/code&gt; hook for app-side override.&lt;/p&gt;

&lt;p&gt;The counterpart to auto-layout on the read side is the drag-and-drop &lt;a href="https://wuic-framework.com/blog/dashboard-designer-drag-and-drop-metadata" rel="noopener noreferrer"&gt;dashboard designer&lt;/a&gt; — UI that writes JSON metadata, and how designer-saved boards survive runtime upgrades. And if you're wondering how those routes got scaffolded in the first place, start &lt;a href="https://wuic-framework.com/blog/sql-table-to-crud-form-in-30-seconds" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>responsive</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Enterprise reports in three steps: SQL view drag-and-drop designer menu entry</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Tue, 21 Jul 2026 12:37:53 +0000</pubDate>
      <link>https://dev.to/wuicframework/building-reports-without-code-sql-view-to-mrt-to-printed-pdf-in-one-route-5bgj</link>
      <guid>https://dev.to/wuicframework/building-reports-without-code-sql-view-to-mrt-to-printed-pdf-in-one-route-5bgj</guid>
      <description>&lt;p&gt;The reporting story in most enterprise apps goes like this: build a stored procedure, expose it via a custom controller, write a TypeScript service to call it, embed a Stimulsoft (or Crystal, or Telerik) viewer, manually wire the parameters, manually handle the per-user filter, manually validate the permissions. Multiply by 40 reports. Each one is a tiny project.&lt;/p&gt;

&lt;p&gt;The WUIC version: write a SQL view, scaffold it as a metadata route, lay the report out in the embedded designer (it saves into the route's folder for you), add a menu entry pointing at the route's report viewer. The framework picks it up. The same auth, per-user filtering, and column permissions that drive list pages apply to the report — for free, because it's the same route.&lt;/p&gt;

&lt;p&gt;This post walks through the contract between the report, the route, and the viewer, and why naming discipline matters more here than anywhere else in the framework.&lt;/p&gt;

&lt;h2&gt;
  
  
  The example
&lt;/h2&gt;

&lt;p&gt;Say you want a "Sales by Region" report that aggregates &lt;code&gt;opportunities&lt;/code&gt; joined with &lt;code&gt;accounts&lt;/code&gt; and groups by region. The SQL is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;VIEW&lt;/span&gt; &lt;span class="n"&gt;vw_sales_by_region&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;opportunity_count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;total_amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;avg_deal_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt; &lt;span class="k"&gt;WHEN&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'Won'&lt;/span&gt; &lt;span class="k"&gt;THEN&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="k"&gt;ELSE&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;won_amount&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;opportunities&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;dbo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region_name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the data. Now the report.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaffold the view as a metadata route
&lt;/h2&gt;

&lt;p&gt;The framework needs to know about the view in &lt;code&gt;_metadati__tabelle&lt;/code&gt; so the runtime can resolve it, apply per-user column grants, and inject the user's filter context. Same tool as for tables: the built-in &lt;strong&gt;Scaffolding&lt;/strong&gt; page. Its object dropdown lists tables, views (tagged &lt;code&gt;[VISTA]&lt;/code&gt;) and stored procedures (&lt;code&gt;[STORED]&lt;/code&gt;) — pick &lt;code&gt;vw_sales_by_region&lt;/code&gt;, and this time &lt;strong&gt;leave "Create Menu" unticked&lt;/strong&gt;, because we don't want the view to appear as its own list page; it's reachable through the report's own menu entry.&lt;/p&gt;

&lt;p&gt;Scaffolding a view is the same click as scaffolding a table — the page detects it's a view and routes accordingly. It inserts a &lt;code&gt;_metadati__tabelle&lt;/code&gt; row with &lt;code&gt;mdroutename = 'vw_sales_by_region'&lt;/code&gt; and one &lt;code&gt;_metadati__colonne&lt;/code&gt; row per column. (For automation it's the &lt;code&gt;scaffolding.scaffoldView&lt;/code&gt; proxy method, sibling of &lt;code&gt;scaffoldTable&lt;/code&gt; — but as with tables, the page is how you do it interactively.)&lt;/p&gt;

&lt;p&gt;The columns are now metadata. The framework knows that &lt;code&gt;total_amount&lt;/code&gt; is a decimal that should render with thousand separators, that &lt;code&gt;region_name&lt;/code&gt; is a string used as the human-friendly group key, and that the view has 6 columns. None of that is in the report yet — it's in the metadata, and the report will read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design the report against the route
&lt;/h2&gt;

&lt;p&gt;You don't hand-write the &lt;code&gt;.mrt&lt;/code&gt; XML. WUIC ships an &lt;strong&gt;embedded Stimulsoft report designer&lt;/strong&gt; in the browser, at &lt;code&gt;#/&amp;lt;route&amp;gt;/report-designer&lt;/code&gt;. When you open it for a scaffolded route, the backend seeds the report's data dictionary with that route's data source already wired up: the view's columns are sitting in the fields list, ready to drag onto the canvas. You lay out bands, drop fields, preview on real data — the designer writes the XML.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Freport-designer__report-designer-animation__desktop.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Freport-designer__report-designer-animation__desktop.gif" alt="Report designer — embedded Stimulsoft designer in the browser, drag fields onto the canvas, preview on real data" width="719" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What it generates is worth understanding, because it's the contract the viewer relies on. The &lt;code&gt;.mrt&lt;/code&gt; is XML, and the convention is that &lt;strong&gt;the SQL data source is named after the metadata route&lt;/strong&gt; (spaces replaced by underscores):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;DataSources&amp;gt;&lt;/span&gt;
  &lt;span class="c"&gt;&amp;lt;!-- StiSqlSource named after the route --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;vw_sales_by_region&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"Stimulsoft.Report.Dictionary.StiSqlSource"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Name&amp;gt;&lt;/span&gt;vw_sales_by_region&lt;span class="nt"&gt;&amp;lt;/Name&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;Alias&amp;gt;&lt;/span&gt;vw_sales_by_region&lt;span class="nt"&gt;&amp;lt;/Alias&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;SqlCommand&amp;gt;&lt;/span&gt;SELECT 1 AS [__autogenerated], region_name, total_amount, ... FROM vw_sales_by_region&lt;span class="nt"&gt;&amp;lt;/SqlCommand&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/vw_sales_by_region&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/DataSources&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;…and every binding expression in the report references &lt;code&gt;{vw_sales_by_region.field_name}&lt;/code&gt; — again, filled in by the designer as you drag fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;Text&amp;gt;&lt;/span&gt;{vw_sales_by_region.region_name}&lt;span class="nt"&gt;&amp;lt;/Text&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;Text&amp;gt;&lt;/span&gt;{vw_sales_by_region.total_amount}&lt;span class="nt"&gt;&amp;lt;/Text&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The name match is a convention, not a hard wall: at render time the backend first looks up the data source by route name, then falls back to a case-insensitive match on name or alias, and finally — if the report has exactly one SQL source — uses that one. But rely on the convention; the fallbacks exist for legacy reports.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;1 AS [__autogenerated]&lt;/code&gt; sentinel column. That's how the engine tells auto-generated queries apart from hand-written ones. If the sentinel is present (or the command is empty), the backend &lt;strong&gt;regenerates the query on every render&lt;/strong&gt; from the current metadata and the current user — visible columns, lookup joins, user-scoped filters. If you've replaced the SQL with your own hand-written query, the sentinel is gone and the engine leaves your SQL alone.&lt;/p&gt;

&lt;p&gt;You don't write a backend endpoint for the report. You don't write a TypeScript service. The report is a &lt;code&gt;.mrt&lt;/code&gt; file in a folder, and the framework finds it, rebinds its connection to the route's database, and executes it with the caller's identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the .mrt goes on disk
&lt;/h2&gt;

&lt;p&gt;Convention — in the &lt;strong&gt;project root&lt;/strong&gt; of the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;App&amp;gt;/Reports/&amp;lt;route&amp;gt;/&amp;lt;filename&amp;gt;.mrt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For our example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CrmApp/Reports/vw_sales_by_region/vw_sales_by_region.mrt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;lt;filename&amp;gt;&lt;/code&gt; is arbitrary — if you omit &lt;code&gt;reportName&lt;/code&gt; the viewer defaults to &lt;code&gt;Report.mrt&lt;/code&gt;. What matters is the folder is named after the route, because the viewer constructs the file path from it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;#/vw_sales_by_region/report-viewer?reportName=vw_sales_by_region.mrt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop that URL into a menu entry in &lt;code&gt;_metadati__menu&lt;/code&gt; and it shows up in the navigation. Click it, the viewer opens, the report renders with live data.&lt;/p&gt;

&lt;p&gt;Multiple &lt;code&gt;.mrt&lt;/code&gt; files per route are supported — say &lt;code&gt;summary.mrt&lt;/code&gt; and &lt;code&gt;detailed.mrt&lt;/code&gt; for the same view, with the menu offering both as options. Each menu entry passes its own &lt;code&gt;reportName=&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One backend prerequisite: &lt;code&gt;AppSettings.reportQueryTimeout&lt;/code&gt; must exist in &lt;code&gt;appsettings.json&lt;/code&gt; (e.g. &lt;code&gt;"120"&lt;/code&gt;). The viewer parses it to set the SQL command timeout on every data source, and a missing value is a 500 at render time, not a graceful default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's reused from the rest of the framework
&lt;/h2&gt;

&lt;p&gt;This is where the metadata-driven story pays off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth.&lt;/strong&gt; The viewer endpoints run the same authentication the rest of the API uses. No separate report login. If the &lt;code&gt;.mrt&lt;/code&gt; declares a &lt;code&gt;user_id&lt;/code&gt; variable, it's set to the authenticated user at render — usable directly in hand-written SQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filter context.&lt;/strong&gt; For auto-generated queries, the backend rebuilds the SQL per render via the same query builder the list page uses, so user-scoped filters (e.g. &lt;code&gt;country_code IN (user's territory)&lt;/code&gt;) apply to the report automatically. Filters passed on the URL flow into the same rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-column grants.&lt;/strong&gt; Column visibility is resolved per user and role — the same grant rules (&lt;code&gt;mc_grant_by_default&lt;/code&gt;, role/company grants) the list page applies. A column the user isn't granted never enters the generated query, so it never reaches Stimulsoft at all. Caveat: this applies to the auto-generated query. Hand-written SQL in the .mrt is preserved verbatim, which means it also bypasses the column gate — you own that trade-off.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n.&lt;/strong&gt; The designer seeds field aliases from &lt;code&gt;_metadati__colonne.mc_display_string_in_view&lt;/code&gt;, and the viewer calls Stimulsoft's report localization with the app's current language at render time. Labels follow the language the user is already using in the rest of the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching.&lt;/strong&gt; Stimulsoft's report and image cache are wired per route under &lt;code&gt;Reports/cache/&amp;lt;route&amp;gt;&lt;/code&gt;; the metadata snapshot is already in memory. On the demo dataset a report renders in about a second.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you'd have rolled by hand in a custom controller — auth, filter context, per-column gate, i18n, caching — is the framework's default for every route.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on the dictionary
&lt;/h2&gt;

&lt;p&gt;One detail worth calling out about that designer: the pre-populated dictionary includes the fields of every &lt;strong&gt;lookup&lt;/strong&gt; the route declares, not just the view's own columns — so a foreign-key column shows up with its resolved display fields, ready to drop into the layout. If you add or remove metadata columns later, reopen the designer to regenerate the dictionary.&lt;/p&gt;

&lt;p&gt;Saving from the designer publishes it: the &lt;code&gt;.mrt&lt;/code&gt; lands in the route's folder automatically. There's no separate "register report" step — the folder structure IS the registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Naming discipline (a real warning)
&lt;/h2&gt;

&lt;p&gt;We mentioned in the &lt;a href="https://wuic-framework.com/blog/sql-table-to-crud-form-in-30-seconds" rel="noopener noreferrer"&gt;scaffolding post&lt;/a&gt; that naming discipline is the hardest thing about a metadata-driven framework, and reports are where it hurts the most.&lt;/p&gt;

&lt;p&gt;Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;View names are stable contracts.&lt;/strong&gt; A &lt;code&gt;.mrt&lt;/code&gt; file references &lt;code&gt;vw_sales_by_region.total_amount&lt;/code&gt;. Rename the view (or the column) and every report referencing it breaks. The framework can't auto-rewrite the .mrt for you; from its perspective the file is opaque XML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filename is part of the URL.&lt;/strong&gt; A menu entry at &lt;code&gt;?reportName=sales-summary.mrt&lt;/code&gt; is bound to that filename. Rename &lt;code&gt;sales-summary.mrt&lt;/code&gt; to &lt;code&gt;summary.mrt&lt;/code&gt; and the menu entry breaks until you update it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the framework does to make these failures visible instead of mysterious:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A missing or misnamed report file returns a &lt;strong&gt;typed error envelope&lt;/strong&gt; (&lt;code&gt;errors.report.not_found&lt;/code&gt;, HTTP 404) carrying the route, the report name, and the path it looked at — localized in the UI instead of a raw stacktrace.&lt;/li&gt;
&lt;li&gt;The client runs a lightweight preflight before mounting the Stimulsoft viewer, because the viewer itself crashes ungracefully on any non-200. If the file isn't there, you get a clean localized dialog and the viewer never mounts.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;__autogenerated&lt;/code&gt; sentinel keeps the engine from clobbering hand-written SQL on re-render — the most common way a "working yesterday" report used to die.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the report engine does NOT do
&lt;/h2&gt;

&lt;p&gt;In the same spirit as our other posts on this framework — here's what's deliberately out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cross-route reports as a first-class concept.&lt;/strong&gt; A report is bound to one metadata route, but that doesn't mean it can only see one table. The auto-generated query already joins every lookup the route declares: if &lt;code&gt;opportunities&lt;/code&gt; has &lt;code&gt;account_id&lt;/code&gt; as a lookup on &lt;code&gt;accounts&lt;/code&gt;, the account's display fields show up as draggable fields in the designer dictionary. For joins the metadata doesn't capture, you edit the SQL by hand in the designer — the sentinel logic then preserves your query. A SQL view (like this post's example) remains the cleanest option for heavy aggregations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stored procedures as report sources.&lt;/strong&gt; Reports run against routes backed by tables and views, executing SQL from the .mrt. There is no stored-procedure scaffolding for reports. For the classic "print one invoice" case you don't need one anyway: declare a report &lt;strong&gt;variable&lt;/strong&gt; flagged as SQL parameter, use it as &lt;code&gt;@invoice_id&lt;/code&gt; in the query, and pass the value on the URL (&lt;code&gt;...&amp;amp;parameters=invoice_id||eq||42&lt;/code&gt;). The framework fills the variable; the query stays a query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Designer customization at runtime.&lt;/strong&gt; The end-user can't edit the .mrt in the browser. Editing is in the designer (embedded or standalone), and saves go to the same &lt;code&gt;.mrt&lt;/code&gt; file on disk. If you need user-customizable layouts, the right answer is the &lt;a href="https://wuic-framework.com/blog/dashboard-designer-drag-and-drop-metadata" rel="noopener noreferrer"&gt;dashboard designer&lt;/a&gt; (which writes JSON metadata, not .mrt), not the report engine.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The public &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;demo&lt;/a&gt; ships seeded reports built on the tutorial data — open one from the menu and it renders in the embedded viewer with print, export-to-PDF, and export-to-Excel out of the box.&lt;/p&gt;

&lt;p&gt;To build your own you need write access to the &lt;code&gt;Reports/&lt;/code&gt; folder, so on the hosted demo this part is read-only for visitors — but the workflow is exactly the same on a self-hosted install: scaffold a view, save a .mrt from the embedded designer, add the menu entry.&lt;/p&gt;

&lt;p&gt;If reporting is the "output" half of the metadata story, the input half is just as automated — see how the same routes behave &lt;a href="https://wuic-framework.com/blog/mobile-first-auto-layout-zero-config" rel="noopener noreferrer"&gt;on a phone with zero configuration&lt;/a&gt;, or how the &lt;a href="https://wuic-framework.com/blog/workflow-engine-graph-source-of-truth" rel="noopener noreferrer"&gt;workflow engine&lt;/a&gt; drives state transitions over the same metadata.&lt;/p&gt;

</description>
      <category>reports</category>
      <category>sql</category>
      <category>lowcode</category>
      <category>pdf</category>
    </item>
    <item>
      <title>From SQL table to working CRUD form in 30 seconds: a metadata-driven scaffolding approach</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Wed, 15 Jul 2026 15:33:02 +0000</pubDate>
      <link>https://dev.to/wuicframework/from-sql-table-to-working-crud-form-in-30-seconds-a-metadata-driven-scaffolding-approach-1oaj</link>
      <guid>https://dev.to/wuicframework/from-sql-table-to-working-crud-form-in-30-seconds-a-metadata-driven-scaffolding-approach-1oaj</guid>
      <description>&lt;p&gt;The cleanest demo of WUIC is also the boring one: write a SQL &lt;code&gt;CREATE TABLE&lt;/code&gt;, click one button, refresh the browser. There's a working list page, an edit form, validation rules, lookup widgets, sortable columns, mobile responsiveness — and you wrote no Angular. No controller. No service. No DTOs.&lt;/p&gt;

&lt;p&gt;This post walks through what's actually happening when you do that, and what it's &lt;em&gt;not&lt;/em&gt; doing (because over-promising is how low-code platforms lose trust).&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30 seconds, end-to-end
&lt;/h2&gt;

&lt;p&gt;Suppose you want to add a "vendors" entity to your CRM. You'd typically do this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;vendors&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id&lt;/span&gt;              &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;            &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;vat_number&lt;/span&gt;      &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;country_code&lt;/span&gt;    &lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contact_email&lt;/span&gt;   &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;contact_phone&lt;/span&gt;   &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;notes&lt;/span&gt;           &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;created_at&lt;/span&gt;      &lt;span class="n"&gt;DATETIME2&lt;/span&gt;     &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;SYSUTCDATETIME&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;is_active&lt;/span&gt;       &lt;span class="nb"&gt;BIT&lt;/span&gt;           &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open the built-in &lt;strong&gt;Scaffolding&lt;/strong&gt; page (it ships as an admin menu entry — no code, no external tool). Pick the connection, the database, and the &lt;code&gt;vendors&lt;/code&gt; table from the dropdowns, tick &lt;strong&gt;Create Menu&lt;/strong&gt;, and hit &lt;strong&gt;Scaffold Table&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1ez2v6apfgwj7zcne4k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fd1ez2v6apfgwj7zcne4k.png" alt="The built-in Scaffolding page: pick connection → database → table, tick Create Menu, hit Scaffold Table" width="780" height="979"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The same page has a few neighbours: &lt;strong&gt;Scaffold DB&lt;/strong&gt; does every table in a schema at once, &lt;strong&gt;Scaffold Columns&lt;/strong&gt; re-reads a table you've already scaffolded and adds the columns you added since (without clobbering your manual tweaks), and &lt;strong&gt;Scaffold OData&lt;/strong&gt; exposes the route as an OData endpoint. It's all driven by the same handler underneath — scriptable via the &lt;code&gt;scaffolding.scaffoldTable&lt;/code&gt; proxy method if you'd rather automate it, but the page is how you do it day-to-day.&lt;/p&gt;

&lt;p&gt;Refresh the app. There's a new menu entry "Vendors" with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;list page&lt;/strong&gt; at &lt;code&gt;/vendors/list&lt;/code&gt; showing all rows with sort, filter, group, paginate.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;edit form&lt;/strong&gt; at &lt;code&gt;/vendors/edit/:id&lt;/code&gt; with one field per column, validation rules from the schema.&lt;/li&gt;
&lt;li&gt;An &lt;strong&gt;API&lt;/strong&gt; at &lt;code&gt;/api/Meta/AsmxProxy/vendors.crudRead&lt;/code&gt; etc., ready to call.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;mobile layout&lt;/strong&gt; that collapses the table into a card stack below 768px.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the headline. Now the substance.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yyv18ilrs1jsixqua02.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3yyv18ilrs1jsixqua02.gif" alt="Auto-generated list-grid: sort, filter, group, paginate — all from metadata, zero per-route code" width="599" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the scaffolder actually inspects
&lt;/h2&gt;

&lt;p&gt;The endpoint handler reads three things from the database:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;INFORMATION_SCHEMA.COLUMNS&lt;/code&gt;.&lt;/strong&gt; Column name, data type, nullable, default value, max length. This drives the field widget choice (text vs. textarea vs. number vs. date vs. checkbox), the validation (required from &lt;code&gt;IS_NULLABLE&lt;/code&gt;, max length from the type), and the formatting (decimal places, date format).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;INFORMATION_SCHEMA.KEY_COLUMN_USAGE&lt;/code&gt; + &lt;code&gt;REFERENTIAL_CONSTRAINTS&lt;/code&gt;.&lt;/strong&gt; Foreign keys turn into &lt;strong&gt;lookup widgets&lt;/strong&gt; automatically. If &lt;code&gt;vendors.country_code&lt;/code&gt; references &lt;code&gt;countries.code&lt;/code&gt;, the form shows a dropdown populated from the &lt;code&gt;countries&lt;/code&gt; route.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Identity / unique constraints.&lt;/strong&gt; &lt;code&gt;id&lt;/code&gt; columns get hidden in the edit form. Unique columns get a "value already taken" validation message wired to the API's duplicate-key error response.&lt;/p&gt;

&lt;p&gt;That's it. No annotations, no decorators, no &lt;code&gt;[Required]&lt;/code&gt; attributes scattered across DTOs. The information already lives in the schema; we just read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What ends up in metadata
&lt;/h2&gt;

&lt;p&gt;The scaffolder inserts two rows into the WUIC metadata tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;_metadati__tabelle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;md_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;md_nome_tabella&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mdroutename&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vendors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nf"&gt;_metadati__colonne &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;one&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="nx"&gt;per&lt;/span&gt; &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;mc_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1781&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;md_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mc_nome_colonna&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="nx"&gt;mc_ui_column_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="nx"&gt;mc_validation_required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mc_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1782&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mc_nome_colonna&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vat_number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="na"&gt;mc_ui_column_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;mc_validation_required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;mc_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1783&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;142&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mc_nome_colonna&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;country_code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="na"&gt;mc_ui_column_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lookupByID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;mc_ui_lookup_entity_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;countries&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once these rows exist, the runtime takes over. There is no codegen step, no file written to disk, no Angular &lt;code&gt;ng generate&lt;/code&gt;. The Angular components (&lt;code&gt;&amp;lt;wuic-list-grid&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;wuic-parametric-dialog&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;wuic-field-editor&amp;gt;&lt;/code&gt;) read the metadata at runtime and self-configure.&lt;/p&gt;

&lt;p&gt;This is the core trick: there is one set of generic UI components in the framework, and they specialise themselves based on metadata. We don't generate code per entity. We generate metadata, and the generic code reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80/20
&lt;/h2&gt;

&lt;p&gt;The 80% of the form is right out of the box. The 20% you tweak by hand falls into a few buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Field labels.&lt;/strong&gt; The scaffolder uses the SQL column name (&lt;code&gt;vat_number&lt;/code&gt; → "Vat number"). For customer-facing UI you want "VAT number" or "P.IVA". You edit one row in &lt;code&gt;_metadati__colonne&lt;/code&gt; (&lt;code&gt;mc_display_string_in_view&lt;/code&gt;) and reload the metadata. Took 10 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Field grouping.&lt;/strong&gt; By default every column goes in one section. To split "Identity" / "Contact" / "Notes" tabs you set &lt;code&gt;mc_edit_associated_tab&lt;/code&gt; per column. Two SQL &lt;code&gt;UPDATE&lt;/code&gt;s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom validation.&lt;/strong&gt; Things SQL can't express — &lt;em&gt;"contact_email must end in &lt;a class="mentioned-user" href="https://dev.to/customer"&gt;@customer&lt;/a&gt;'s allowed domain"&lt;/em&gt; — go into &lt;code&gt;mc_validation_callback&lt;/code&gt;, a JS one-liner stored in metadata. Runs both client-side and server-side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom widget.&lt;/strong&gt; A specific column needs a custom Angular component (e.g. a colour picker). You write the component once, register it as a custom widget, and any column with &lt;code&gt;mc_ui_column_type: 'colorPicker'&lt;/code&gt; gets it. Wrote it for one entity, reusable everywhere after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tweak is a SQL &lt;code&gt;UPDATE&lt;/code&gt; away from being live. No rebuild, no Angular recompile.&lt;/p&gt;

&lt;p&gt;Same metadata, different archetype — the route can render as a kanban board or a spreadsheet without re-scaffolding:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhbr7875ud8k9zcnd7do.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvhbr7875ud8k9zcnd7do.gif" alt="Kanban archetype rendered from the same metadata" width="800" height="394"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4grnzbjgq75icmx9swxp.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4grnzbjgq75icmx9swxp.gif" alt="Spreadsheet archetype — Excel-style bulk edit, also from the same metadata route" width="719" height="354"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What scaffolding &lt;em&gt;does not&lt;/em&gt; do
&lt;/h2&gt;

&lt;p&gt;We've seen low-code platforms over-promise here. Here's what scaffolding deliberately won't try:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Business logic.&lt;/strong&gt; Workflow transitions, conditional approvals, computed fields — these are &lt;em&gt;not&lt;/em&gt; in the schema, so the scaffolder doesn't invent them. You configure them afterwards (workflow engine has its own metadata; computed fields have callbacks).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layout decisions.&lt;/strong&gt; The scaffolder lays out fields in column order. For complex forms that's wrong; you edit a few &lt;code&gt;mc_ordine&lt;/code&gt; values and &lt;code&gt;mc_edit_associated_tab&lt;/code&gt; to fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions.&lt;/strong&gt; Brand-new tables get default "all admin" permissions. Granular per-role / per-row rules live in the &lt;code&gt;_mtdt__tnt__trzzzioni__*&lt;/code&gt; tables and have to be configured deliberately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cascade deletes &amp;amp; soft-delete columns.&lt;/strong&gt; If your schema has a &lt;code&gt;deleted&lt;/code&gt; flag, the scaffolder doesn't auto-filter it; we make this explicit per-route to avoid surprises (one of our oldest bugs was &lt;em&gt;"why are deleted records still showing in this one screen"&lt;/em&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How it scales
&lt;/h2&gt;

&lt;p&gt;The team that uses this most internally has scaffolded ~140 routes. Each one is a SQL DDL change + an &lt;code&gt;UPDATE _metadati__*&lt;/code&gt; for tweaks. The runtime metadata cache is a single in-memory snapshot, refreshed via &lt;code&gt;MetaService.invalidateMetadataRuntime&lt;/code&gt; when a row changes. The whole thing is fast: a 100-column table with three FK lookups scaffolds in around 600ms server-side.&lt;/p&gt;

&lt;p&gt;The interesting bottleneck isn't performance — it's &lt;em&gt;naming discipline&lt;/em&gt;. When the metadata table has 140 routes and 1,800 columns, "what does &lt;code&gt;mc_extra_2&lt;/code&gt; mean?" becomes an actual question. We've ended up with an internal style guide for column naming. Boring, important.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to scaffold
&lt;/h2&gt;

&lt;p&gt;A short list, because honest documentation matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tables that already have a heavily customised hand-written UI.&lt;/strong&gt; Re-scaffolding overwrites your tweaks. Use the dedicated update endpoint that preserves manual edits, not the full scaffold.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tables you don't actually want users to see.&lt;/strong&gt; Auto-generating a UI for &lt;code&gt;_audit_log&lt;/code&gt; is a footgun. Scaffolding is opt-in per table; we keep it that way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polymorphic / EAV schemas.&lt;/strong&gt; If your "products" table has a JSON &lt;code&gt;attributes&lt;/code&gt; column with arbitrary keys, scaffolding will give you a textarea for that column. Fine, but obviously not the UX you'd hand-craft.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The Scaffolding page runs on the &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt;. The demo data is reset every 24 hours, so you can &lt;code&gt;CREATE TABLE foo (id int identity, name nvarchar(100))&lt;/code&gt;, scaffold it, see the auto-generated CRUD, then leave — by tomorrow the schema is clean again.&lt;/p&gt;

&lt;p&gt;If you want to read how it works under the hood: the scaffolder entry point is &lt;code&gt;scaffolding.scaffoldTable&lt;/code&gt; (one gateway per DBMS — SQL Server, MySQL, PostgreSQL, Oracle — because &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; is only &lt;em&gt;mostly&lt;/em&gt; portable). The in-product codebase chatbot (&lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;covered here&lt;/a&gt;) can find the exact files for you faster than I can paste links.&lt;/p&gt;

&lt;p&gt;If this sounded interesting, the follow-ups are already out: how the &lt;strong&gt;mobile auto-layout&lt;/strong&gt; turns a desktop table into a card stack with &lt;a href="https://wuic-framework.com/blog/mobile-first-auto-layout-zero-config" rel="noopener noreferrer"&gt;zero per-screen config&lt;/a&gt;, and how the &lt;strong&gt;workflow engine&lt;/strong&gt; runs multi-step business processes &lt;a href="https://wuic-framework.com/blog/workflow-engine-graph-source-of-truth" rel="noopener noreferrer"&gt;from a graph metadata table&lt;/a&gt;. Or skip the reading and &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;try the live demo&lt;/a&gt; — the schema resets every night at 4:00, so you can scaffold anything you like.&lt;/p&gt;

</description>
      <category>database</category>
      <category>angular</category>
      <category>sqlserver</category>
      <category>lowcode</category>
    </item>
    <item>
      <title>The chatbot's toolbox: the actions an LLM can apply to your app</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Thu, 18 Jun 2026 07:31:13 +0000</pubDate>
      <link>https://dev.to/wuicframework/the-chatbots-toolbox-the-actions-an-llm-can-apply-to-your-app-2oj0</link>
      <guid>https://dev.to/wuicframework/the-chatbots-toolbox-the-actions-an-llm-can-apply-to-your-app-2oj0</guid>
      <description>&lt;p&gt;A chatbot that only answers questions is a search box with manners. Ours does more: it can &lt;strong&gt;propose concrete changes&lt;/strong&gt; to the app you're looking at. You describe what you want in plain language, the model picks the right tool, and you get a proposal — a chip with an &lt;strong&gt;Apply&lt;/strong&gt; button, the generated code, the target route, and the model's rationale, all visible before anything happens.&lt;/p&gt;

&lt;p&gt;If you know MCP (the Model Context Protocol), the mental model is the same: hand the model a catalogue of typed tools and let it call them. We define the tools inline against the Anthropic tool-use API rather than behind an MCP server, but the shape is identical — a name, a description that tells the model &lt;em&gt;when&lt;/em&gt; to use it, and a typed argument list. The model never touches a database; it produces a structured intent and the framework owns the execution.&lt;/p&gt;

&lt;p&gt;One rule sits above all of them: &lt;strong&gt;nothing is applied without a click.&lt;/strong&gt; The model proposes, you dispose.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — overview clip: asking the chatbot for an action and the resulting "Apply" chip appearing under the answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Below is the catalogue, one tool at a time, each with the kind of prompt that triggers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Grid actions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_toolbar_action&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Adds a custom button to a list grid's toolbar, operating on the current selection, with a generated JavaScript callback.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;on the orders grid add a button that exports the selected rows to CSV&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;toolbar&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;orders&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;datasource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSelectedRows&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;toCsv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orders.csv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;csv&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff60o3zfomn8eue57wnst.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ff60o3zfomn8eue57wnst.gif" alt="Demo propose_toolbar_action — bulk archive on cities" width="720" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_row_action&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Adds a button to a single row (in the row's action dropdown), with a callback that receives that record.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;put an Approve button on each row of the requests grid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="nx"&gt;action&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;requests&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/requests/approve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{...&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;...});&lt;/span&gt;
                &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;datasource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetchData&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Frag-row-action.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Frag-row-action.gif" alt="Demo propose_row_action — per-row PDF generation" width="759" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conditional styling
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_table_style&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Applies a CSS class to a whole row when a JS condition is true (a row-level highlight).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;highlight in red the rows whose due date is in the past&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;danger&lt;/span&gt;
      &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;due_date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7qjd2iksoebq91gqcbf.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk7qjd2iksoebq91gqcbf.gif" alt="Demo propose_table_style — conditional row coloring" width="720" height="387"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_column_style&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Same idea, scoped to a single cell instead of the whole row.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;give the status cell a green background when it's 'OK'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;column&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;status&lt;/span&gt;
      &lt;span class="kd"&gt;class&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;cell&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;
      &lt;span class="nx"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcl2zvupydv036ibbf670.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcl2zvupydv036ibbf670.gif" alt="Demo propose_column_style — single-cell conditional coloring" width="759" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Cell rendering and form titles
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_display_formula&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Replaces a column's rendered value in the list with a custom HTML/Angular template — a badge, an icon, a coloured number.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;You:  "show priority as a coloured badge: green/yellow/red"

Bot:  display formula  ▸ Apply
      column:  priority
      template: &lt;span class="nt"&gt;&amp;lt;span&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"badge"&lt;/span&gt; &lt;span class="na"&gt;[class.high]=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;{{ priority }}&lt;span class="nt"&gt;&amp;lt;/span&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: the priority column rendered as coloured badges.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_form_title_formula&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Computes the edit-form's title dynamically from the record being edited.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;the edit form title should read 'Customer {company_name}'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="nx"&gt;formula&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;customers&lt;/span&gt;
      &lt;span class="nx"&gt;formula&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Customer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;company_name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — clip: opening a record and seeing the dynamic title in the dialog header.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Form callbacks
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_default_value_callback&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Generates the callback that prefills a field when a new record is opened.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default created_date to today on new records&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;created_date&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_nome_colonna&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: the insert form opening with the date prefilled.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_custom_validation&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Generates a validation that blocks the save when a rule — possibly cross-field — isn't met, with a message.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;validate that population can't be negative, block the save with a message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;custom&lt;/span&gt; &lt;span class="nx"&gt;validation&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;population&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;field&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mc_nome_colonna&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                  &lt;span class="nx"&gt;vr&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Population cannot be negative&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — clip: typing a negative value, hitting Save, and the validation blocking it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_selection_changed&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Hooks the change of a lookup/select field to recompute or prefill other fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;when the customer lookup changes, copy its VAT number into the record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;selection&lt;/span&gt; &lt;span class="nx"&gt;changed&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;column&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="nx"&gt;customer&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vat_number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer__lookup_obj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;vat&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — clip: picking a customer and watching the VAT field auto-fill.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_lifecycle_callback&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Hooks a record lifecycle event — before/after save, after load — for a side effect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;before save, uppercase the city name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="nx"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="nx"&gt;callback&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="nx"&gt;Apply&lt;/span&gt;
      &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;before_save&lt;/span&gt;
      &lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CityName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CityName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — clip: saving a lowercase name and seeing it stored uppercase.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Metadata
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_simple_metadata_update&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Patches a simple metadata field — page size, a caption, hide-in-list/edit, a basic flag — without opening the metadata editor.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:  "set the cities grid page size to 50"

Bot:  metadata update  ▸ Apply
      route:  cities
      field:  page size → 50
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: the grid paging at 50 rows after Apply.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_metadata_column_create&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Creates a new column on a route — including a computed (non-physical) one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:  "add a computed column total_chars = LEN(city_name)"

Bot:  create column  ▸ Apply
      route:    cities
      column:   total_chars (number, computed)
      formula:  LEN([Application].[Cities].[CityName])
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: the new computed column showing in the grid.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  SQL (super-admin)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_sql_metadata_field&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Writes a raw SQL fragment into a metadata field — a custom JOIN, a computed expression, a custom SELECT clause — concatenated at runtime into the auto-generated queries. The model generates the fragment in the &lt;strong&gt;active provider's dialect&lt;/strong&gt; (MSSQL / MySQL / PostgreSQL / Oracle quoting and syntax).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nv"&gt;"on the orders total column, compute price × quantity"&lt;/span&gt;

&lt;span class="n"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;SQL&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="n"&gt;Apply&lt;/span&gt;   &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;super&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="k"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;computed&lt;/span&gt; &lt;span class="n"&gt;expression&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the only tool gated harder than a click: it requires super-admin privileges server-side and writes an audit log row on every application.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: the computed total column + the audit log entry.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Designer canvas
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;propose_designer_inject&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The one tool that does &lt;strong&gt;not&lt;/strong&gt; write metadata. On the dashboard designer page it injects or edits components on the canvas — the same in-memory tree the drag-and-drop UI manipulates. Nothing persists until you click "Save dashboard", and the designer's undo/redo covers the model's edits exactly like a human's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You:  "add a grid bound to cities"   (on the designer page)

Bot:  designer inject  ▸ Apply
      injects: DATASOURCE + DATAREPEATER, bound and configured for route 'cities'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It knows the full catalogue of canvas tools and their editable properties, and it fuzzy-matches sloppy route names (&lt;em&gt;"provincie"&lt;/em&gt; → &lt;code&gt;stateprovinces&lt;/code&gt;) before committing to an argument.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Frag-designer.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Frag-designer.gif" alt="Demo propose_designer_inject — master-detail injection in designer canvas" width="599" height="322"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Before it acts: asking for the schema
&lt;/h2&gt;

&lt;p&gt;Every tool above needs &lt;em&gt;real&lt;/em&gt; names — the actual column name on the route, the SQL schema and table, the join alias behind a lookup. Early versions handed all of that to the model upfront: the page context was a full dump of every column on the current grid. It bloated every request, action or not.&lt;/p&gt;

&lt;p&gt;So we flipped it. The page context is now minimal — just the route you're on, e.g. &lt;code&gt;cities/list&lt;/code&gt;. When a prompt needs schema the model doesn't already have, it calls one more tool &lt;em&gt;first&lt;/em&gt;:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;request_metadata_detail&lt;/code&gt; — a non-chip, behind-the-scenes tool. &lt;code&gt;{detail: 'columns', route}&lt;/code&gt; returns the real column list and the SQL identity (schema, table, the full qualifier to use in a formula); &lt;code&gt;{detail: 'lookup_columns', route, column}&lt;/code&gt; returns a lookup's join alias and its related columns. The framework resolves it — the model still never touches the database — hands the result back, and &lt;em&gt;then&lt;/em&gt; the model emits the &lt;code&gt;propose_*&lt;/code&gt; with names it looked up instead of names it guessed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;You&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nv"&gt;"show the province lookup as 'id - name'"&lt;/span&gt;

&lt;span class="n"&gt;Bot&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt; &lt;span class="n"&gt;request_metadata_detail&lt;/span&gt; &lt;span class="err"&gt;→&lt;/span&gt; &lt;span class="n"&gt;learns&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="k"&gt;alias&lt;/span&gt;
       &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;StateProvinceID_stateprovinces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="k"&gt;SQL&lt;/span&gt; &lt;span class="n"&gt;metadata&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;  &lt;span class="err"&gt;▸&lt;/span&gt; &lt;span class="n"&gt;Apply&lt;/span&gt;
      &lt;span class="k"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;StateProvinceID_stateprovinces&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="n"&gt;StateProvinceID&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s1"&gt;' - '&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;StateProvinceID_stateprovinces&lt;/span&gt;&lt;span class="p"&gt;].[&lt;/span&gt;&lt;span class="n"&gt;StateProvinceName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the same trust property as everywhere else, one level deeper: the model can't reference a column or an alias that doesn't exist, because it asks before it acts — and the prompt stays lean, fetching only what the question needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quiet tools
&lt;/h2&gt;

&lt;p&gt;Three tools never produce a chip — they shape the conversation instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;remember_fact&lt;/code&gt; / &lt;code&gt;forget_fact&lt;/code&gt; — the model pins high-priority facts it shouldn't lose ("this project uses snake_case columns"). They survive summarisation.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;suggest_followups&lt;/code&gt; — the clickable chips under each answer that propose the next question.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;▶ &lt;strong&gt;Media&lt;/strong&gt; — screenshot: a memory fact pinned in the header + follow-up chips under an answer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why a fixed toolbox
&lt;/h2&gt;

&lt;p&gt;A typed catalogue over a metadata-driven framework is a good fit precisely because the framework already has a small, safe, well-defined surface of "things you can configure". The model never writes arbitrary code that runs unsandboxed; it fills in the blanks of operations the framework was already designed to perform — and you approve each one with your eyes on the preview.&lt;/p&gt;

&lt;p&gt;The chatbot, and this whole action layer, ships with every &lt;a href="https://wuic-framework.com/downloads" rel="noopener noreferrer"&gt;WUIC install&lt;/a&gt; and runs on the &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt;. Open it on a list grid and ask it to colour the overdue rows red. Read the chip. Then click Apply. For how the engine behind it is built and served, see the &lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;architecture post&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>tooluse</category>
      <category>mcp</category>
    </item>
    <item>
      <title>The architecture of an in-product RAG chatbot: from your prompt to the answer</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Thu, 11 Jun 2026 14:09:09 +0000</pubDate>
      <link>https://dev.to/wuicframework/the-architecture-of-an-in-product-rag-chatbot-from-your-prompt-to-the-answer-3iih</link>
      <guid>https://dev.to/wuicframework/the-architecture-of-an-in-product-rag-chatbot-from-your-prompt-to-the-answer-3iih</guid>
      <description>&lt;p&gt;The pitch we kept hearing was: &lt;em&gt;"just embed ChatGPT into your product"&lt;/em&gt;. A week of work, an OpenAI key, you're done. That works for a marketing copilot or a generic FAQ bot. It does &lt;strong&gt;not&lt;/strong&gt; work for the kind of question our users actually ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;Where in the codebase is the multi-tenant authorization handled?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;What does the "savemetadata" endpoint do under the hood?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;How is the demo data restored at 04:00 UTC?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For these questions, the answer that matters is the file path. A confident-sounding paragraph that hallucinates &lt;code&gt;MetaService.cs:123&lt;/code&gt; when the file is actually &lt;code&gt;MetaController.cs:847&lt;/code&gt; is &lt;em&gt;worse&lt;/em&gt; than no answer — it makes the user lose trust faster than a 404 would.&lt;/p&gt;

&lt;p&gt;So we built a real RAG. This post is the architecture: it follows one prompt from the moment you hit send all the way to the cited answer, and explains the choices behind each step.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg3iygjn2lkjfm341lcr6.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fg3iygjn2lkjfm341lcr6.gif" alt="&lt;wuic-rag-chatbot&gt; in action — natural-language question about the codebase, streamed answer with clickable file-path citations"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "in-product RAG" means here
&lt;/h2&gt;

&lt;p&gt;The chatbot lives inside the framework as &lt;code&gt;&amp;lt;wuic-rag-chatbot&amp;gt;&lt;/code&gt;, an Angular standalone component. A user opens any WUIC-built application (or our docs), clicks the floating button bottom-right, asks a natural-language question. The answer comes back with &lt;strong&gt;citations&lt;/strong&gt;: real file paths in the codebase that the user can click to open the matching chunk. The component owns nothing but the conversation — the floating button, the streamed answer, the session list. Everything stateful lives server-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The journey of a single prompt
&lt;/h2&gt;

&lt;p&gt;Here is what actually happens between &lt;em&gt;hit send&lt;/em&gt; and &lt;em&gt;read answer&lt;/em&gt;. Four boxes, in order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;wuic-rag-chatbot&amp;gt;   →   RagController   →   RAG engine   →   Claude
   (Angular)             (.NET)              (retrieval)       (synthesis)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;1 — The component posts the question.&lt;/strong&gt; &lt;code&gt;&amp;lt;wuic-rag-chatbot&amp;gt;&lt;/code&gt; sends &lt;code&gt;POST /api/Rag/Ask&lt;/code&gt; with three things: the question, the current session id, and the &lt;em&gt;route context&lt;/em&gt; — which page you're on, e.g. &lt;code&gt;cities/list&lt;/code&gt;. That context is deliberately minimal: just the current route, not a dump of every column. It's enough for the bot to know &lt;em&gt;which&lt;/em&gt; grid you mean when you say &lt;em&gt;"add a validation to this column"&lt;/em&gt;; the real column names it needs to act are fetched separately, on demand (see &lt;em&gt;A leaner prompt: schema on demand&lt;/em&gt;, below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2 — The controller authenticates and persists.&lt;/strong&gt; &lt;code&gt;RagController&lt;/code&gt; checks the session cookie, loads or creates the chat session, and writes your message to &lt;code&gt;_rag_chat_messages&lt;/code&gt; before doing anything expensive. If the conversation is getting long, this is also where a background summary may already have been folded into the system prompt (more on that below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3 — The engine retrieves.&lt;/strong&gt; The query goes into the retrieval pipeline, which is the heart of the thing. In order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tokenize&lt;/strong&gt; the query with an XLM-RoBERTa SentencePiece tokenizer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect language&lt;/strong&gt;; if it's Italian, translate to English first (cached) — the retriever was tuned on English.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embed&lt;/strong&gt; the query with &lt;code&gt;bge-m3&lt;/code&gt; into a dense vector, and compute its &lt;strong&gt;BM25&lt;/strong&gt; sparse scores in parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fuse&lt;/strong&gt; the dense and sparse rankings with reciprocal-rank fusion → a top-40 candidate set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blend and rerank&lt;/strong&gt;: a min-max blend with an adaptive alpha, then a cross-encoder rerank (the fine-tuned one — see below) over those 40, with small boosts for source type and title match → the &lt;strong&gt;top 8&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;4 — The engine assembles the prompt.&lt;/strong&gt; The top-8 chunks become the context, joined by the route context, any pinned &lt;em&gt;memory facts&lt;/em&gt;, and the rolling conversation summary. That bundle, plus a short system prompt and a catalogue of tools, is what goes to the model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5 — Claude answers — or acts.&lt;/strong&gt; Most of the time the model writes prose with the file paths preserved as citations, and it streams back token by token. But the same call also exposes a &lt;strong&gt;toolbox&lt;/strong&gt;: if your question was actually a request to &lt;em&gt;change&lt;/em&gt; something, the model emits a tool call instead of prose, and you get an "Apply" chip rather than a paragraph. (That action layer is a &lt;a href="https://wuic-framework.com/blog/rag-chatbot-tool-use-framework-integration" rel="noopener noreferrer"&gt;post of its own&lt;/a&gt; — here it's enough to know the retrieval-and-synthesis path and the tool path are the same request, branching only at the model's choice.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6 — The controller closes the loop.&lt;/strong&gt; The assistant message is persisted, the context-window cue in the header is updated from the tokens the API actually reported, and — if history crossed a threshold — an auto-compact is queued so the &lt;em&gt;next&lt;/em&gt; turn starts leaner.&lt;/p&gt;

&lt;p&gt;The rest of this post zooms into the choices behind the interesting steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  A leaner prompt: schema on demand
&lt;/h2&gt;

&lt;p&gt;The bot needs &lt;em&gt;real&lt;/em&gt; names to act — the actual column names of a grid, the SQL schema and table, the join alias behind a lookup. The first version shipped all of that inside the route context: every request carried a full column dump whether the prompt needed it or not. It saturated the window and spent tokens on every turn, prompt or not.&lt;/p&gt;

&lt;p&gt;So we inverted it. The route context is now just the current route. When the model needs schema it doesn't have, it &lt;em&gt;asks&lt;/em&gt; for it: a non-chip tool, &lt;code&gt;request_metadata_detail&lt;/code&gt;, that the engine resolves against the metadata (the model never touches the database) and feeds back as a tool result before the model continues. The retrieval-and-synthesis loop above gains one optional inner hop — &lt;em&gt;ask for the columns, then act&lt;/em&gt; — and the prompt only ever carries what the question actually needs. The same mechanism extends to any metadata dimension — lookups, related routes, enum values — without re-bloating the context. The action layer that consumes those names is a &lt;a href="https://wuic-framework.com/blog/rag-chatbot-tool-use-framework-integration" rel="noopener noreferrer"&gt;post of its own&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape of the index
&lt;/h2&gt;

&lt;p&gt;We chunk the entire codebase by symbol — one chunk per class, one per top-level method — plus one chunk per documentation page section. Around 8,800 source chunks and several thousand doc chunks. Each chunk knows its file path, its symbol type and name, a dense embedding from &lt;a href="https://huggingface.co/BAAI/bge-m3" rel="noopener noreferrer"&gt;BAAI/bge-m3&lt;/a&gt;, and its BM25 sparse vector.&lt;/p&gt;

&lt;p&gt;The choice of bge-m3 was deliberate. We tried OpenAI's &lt;code&gt;text-embedding-3-large&lt;/code&gt; and bge-m3 (heavier, public weights, no API call needed). For our corpus — code with a lot of camelCase identifiers and an Italian/English mix — bge-m3 won by ~6 points on &lt;a href="mailto:hit@8"&gt;hit@8&lt;/a&gt;. More importantly the weights are local, so the retrieval has &lt;strong&gt;zero API surface&lt;/strong&gt; to a third party. A user's question never leaves the box for retrieval; only the synthesis step talks to Claude.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hybrid retrieval, not pure vector
&lt;/h2&gt;

&lt;p&gt;The first version was pure cosine similarity over the embeddings. It worked for "concept" questions (&lt;em&gt;"how does multi-tenant authorization work?"&lt;/em&gt;) but missed precise lookups (&lt;em&gt;"AsmxProxy/MetaService.invalidateMetadataRuntime"&lt;/em&gt;) — the embedding for a verbatim symbol name was less useful than a literal text match.&lt;/p&gt;

&lt;p&gt;Adding BM25 in parallel and fusing with reciprocal rank fixed it, and the &lt;em&gt;failure mode&lt;/em&gt; is the interesting part: BM25 catches the queries that look like grep, vector catches the queries that look like sentences. They cover different mistakes. That's the architectural reason both are in the pipeline rather than one or the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fine-tuning the cross-encoder
&lt;/h2&gt;

&lt;p&gt;The fusion step returns top-40 candidates. The next step is a cross-encoder reranker — a &lt;code&gt;BAAI/bge-reranker-v2-m3&lt;/code&gt; that scores each (query, chunk) pair end-to-end. Slower than the dual-encoder retrieval (≈300 ms vs 30 ms for top-40), much more accurate.&lt;/p&gt;

&lt;p&gt;The base reranker was good. Fine-tuning it with &lt;strong&gt;LoRA&lt;/strong&gt; was &lt;em&gt;much&lt;/em&gt; better, and this is where most of our quality gains came from.&lt;/p&gt;

&lt;p&gt;We mined hard negatives — chunks the dual-encoder returned but a human marked as wrong — and trained a small adapter (rank=16, alpha=32, ~3.4M trainable parameters out of a 568M base). Two iterations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First adapter&lt;/strong&gt;, trained on 11k mined examples, blend 0.85 → hit@8 went from 0.61 (base) to 0.87 on our 603-case eval set. Dramatic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Second adapter&lt;/strong&gt;, retrained on 8k examples remined against the rebuilt index → held the in-distribution number (0.81) but jumped on the holdout test (0.74 → 0.78). Less Goodhart, better generalization. That's the one in production.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                                   hit@8  MRR
base CE, top_n=20, blend=0.65      0.74   0.58
LoRA v2 (11k-mined), top_n=40      0.87   0.76
LoRA v2 (8k-mined, current)        0.81   0.66   ← production default
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The architectural decision here was &lt;em&gt;where&lt;/em&gt; to spend the fine-tuning budget. We tried fine-tuning the bge-m3 retriever itself: marginal gains, big training-infra footprint. An adapter on the reranker, with the dual-encoder left frozen, gives most of the win for a fraction of the cost — and the adapter is 3.4M parameters you can swap without touching the index.&lt;/p&gt;

&lt;h2&gt;
  
  
  Translating Italian queries
&lt;/h2&gt;

&lt;p&gt;Half our users type in Italian. bge-m3 is multilingual, but the cross-encoder was trained mostly on English. Translating Italian queries to English &lt;em&gt;before&lt;/em&gt; the reranker bought ~2 points on &lt;a href="mailto:hit@8"&gt;hit@8&lt;/a&gt;. We use NLLB-200-distilled-600M locally (no API call), with a translation cache that persists across runs — ~80 ms cold-start once, ~12 ms per query after.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Claude for synthesis
&lt;/h2&gt;

&lt;p&gt;Once retrieval has the top 8 chunks, they go to Claude with the question. Two reasons specific to our use case drove that choice over GPT-4o:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Long context without the latency cliff.&lt;/strong&gt; Our top-8 can hit 4–6k tokens combined; Claude handles that without the slowdown we measured on the same payload elsewhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Better at &lt;em&gt;not&lt;/em&gt; answering.&lt;/strong&gt; Our biggest failure mode is the model confidently inventing an answer when retrieval came back empty. With the same "if you don't know, say so" instruction, Claude said so markedly more often when retrieval was bad. We'll take that.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Serving it natively on .NET
&lt;/h2&gt;

&lt;p&gt;The retrieval models are PyTorch-shaped, so the obvious way to serve them is Python. Our v1 was exactly that: a FastAPI process, &lt;code&gt;rag_server.py&lt;/code&gt;, that the .NET controller proxied to. It worked, but it meant a &lt;em&gt;second runtime&lt;/em&gt; on every customer box — a venv, an NSSM-wrapped service on Windows or a systemd unit on Linux — and every &lt;em&gt;"the chatbot returns 502"&lt;/em&gt; support thread ended at the same place: the Python service had died or drifted. Retrieval quality was never the problem; the operational surface was.&lt;/p&gt;

&lt;p&gt;So we ported the whole inference path to &lt;strong&gt;native .NET on ONNX Runtime&lt;/strong&gt;. Training stays in Python — you don't retrain a cross-encoder in C#. Inference moved:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every model exports cleanly to ONNX; &lt;code&gt;Microsoft.ML.OnnxRuntime.Gpu&lt;/code&gt; runs the graphs in-process with a CUDA provider and a transparent CPU fallback.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Microsoft.ML.Tokenizers&lt;/code&gt; gives the same XLM-RoBERTa tokenizer. We gated it against the Python tokenizer on 200 queries: 198 identical, 2 off by a one-token tie-break worth &amp;lt;0.04 of a logit.&lt;/li&gt;
&lt;li&gt;The 8-stage pipeline above was re-implemented stage for stage and diffed against the Python twin: cosine 1.0 on embeddings, 99.4% ranking agreement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The architectural trick that keeps this clean: the engine lives in its &lt;strong&gt;own&lt;/strong&gt; assembly with &lt;strong&gt;zero&lt;/strong&gt; compile-time reference from the main app. Only when the feature is enabled does a custom &lt;code&gt;AssemblyLoadContext&lt;/code&gt; load &lt;code&gt;WuicRagEngine.dll&lt;/code&gt; and its native ONNX dependencies, invoked across a deliberately dumb seam — JSON in, JSON out, by reflection. A build that never touches the chatbot never links a single native binary. The DLL ships next to the app; the ~2.3 GB of weights and index download on first launch into a local folder.&lt;/p&gt;

&lt;p&gt;The payoff isn't speed — GPU numbers are within noise of the Python build. It's the deploy story: &lt;em&gt;"install .NET, unzip, run"&lt;/em&gt; instead of a Python install guide that reads like a support ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;p&gt;A short list of approaches we dropped, so the next person can save the time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vector-only retrieval, no BM25.&lt;/strong&gt; Catastrophic on &lt;code&gt;&amp;lt;symbol&amp;gt;&lt;/code&gt; lookups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pure cross-encoder on top-100 candidates.&lt;/strong&gt; 8× slower than top-40, only +1pp &lt;a href="mailto:hit@8"&gt;hit@8&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-ranking with GPT-4 directly.&lt;/strong&gt; Worked, but 2.5 s/query and 30× the cost of the LoRA reranker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fine-tuning the bge-m3 retriever instead of the cross-encoder.&lt;/strong&gt; Marginal gains, big training footprint. Adapter on the reranker, leave the dual-encoder alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Including diff history as context.&lt;/strong&gt; The bot learned to answer &lt;em&gt;"what changed last week"&lt;/em&gt; by hallucinating diffs. Pulled it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's the chatbot actually for?
&lt;/h2&gt;

&lt;p&gt;Answering questions is half of it. The half we use more is the chatbot &lt;em&gt;doing&lt;/em&gt; things on the app you're looking at — proposing a button on a grid, a row colour rule, a computed column, a metadata patch — through a typed catalogue of tools the model can call. Every proposal is a chip with an &lt;strong&gt;Apply&lt;/strong&gt; button, the generated code, and the target route, all visible before anything runs.&lt;/p&gt;

&lt;p&gt;The headline example: the &lt;strong&gt;dashboard designer&lt;/strong&gt;. With the designer open, ask:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"add a grid bound to provincie"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model fuzzy-matches "provincie" to the real &lt;code&gt;stateprovinces&lt;/code&gt; route, generates the DATASOURCE + DATAREPEATER components configured for it, and drops them on the canvas as a single proposal. Nothing persists until you click "Save dashboard"; designer undo/redo covers the model's edits exactly like a human's. The bot just saved you the drag, the binding panel, and the route lookup.&lt;/p&gt;

&lt;p&gt;The full toolbox — toolbar actions, row actions, conditional styling, custom validations, lifecycle callbacks, metadata patches, even raw SQL fragments in the active dialect — is the subject of the &lt;a href="https://wuic-framework.com/blog/rag-chatbot-tool-use-framework-integration" rel="noopener noreferrer"&gt;follow-up post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The chatbot is part of every &lt;a href="https://wuic-framework.com/downloads" rel="noopener noreferrer"&gt;WUIC install&lt;/a&gt; and runs on our &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt; — open it, click the floating button, ask anything. The retrieval index is the WUIC framework itself, so &lt;em&gt;"how does the dashboard designer save state?"&lt;/em&gt; returns a real answer. And when you want it to &lt;em&gt;do&lt;/em&gt; something rather than explain it, that's the &lt;a href="https://wuic-framework.com/blog/rag-chatbot-tool-use-framework-integration" rel="noopener noreferrer"&gt;tool layer&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>onnx</category>
      <category>dotnet</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The dashboard designer: drag-and-drop that writes JSON metadata, not Angular code</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Wed, 03 Jun 2026 07:55:31 +0000</pubDate>
      <link>https://dev.to/wuicframework/the-dashboard-designer-drag-and-drop-that-writes-json-metadata-not-angular-code-1ff</link>
      <guid>https://dev.to/wuicframework/the-dashboard-designer-drag-and-drop-that-writes-json-metadata-not-angular-code-1ff</guid>
      <description>&lt;p&gt;A common pattern in low-code platforms: the visual designer is a beautiful UI, but it compiles your dashboard into something opaque — a binary export, a per-tenant Angular bundle, an XML the runtime can't pretty-print. Whatever the format, you can't open it in &lt;code&gt;cat&lt;/code&gt;, you can't &lt;code&gt;grep&lt;/code&gt; it, and a "small fix" requires re-opening the designer.&lt;/p&gt;

&lt;p&gt;WUIC ships a visual designer that runs in the browser and writes plain JSON. One row in &lt;code&gt;dom_board&lt;/code&gt;, one JSON column called &lt;code&gt;boardcontent&lt;/code&gt;. Plus optional CSS attached per board in &lt;code&gt;dom_board_sheet&lt;/code&gt;. Pretty-printable. Diff-able. The runtime reads the JSON at load time, pulls in the linked stylesheets, and builds the component tree with no per-tenant build.&lt;/p&gt;

&lt;p&gt;This is the trade-off we made and the parts of it I'd defend.&lt;/p&gt;

&lt;h2&gt;
  
  
  The designer
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Fdesigner__designer-advanced__desktop.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Fdesigner__designer-advanced__desktop.gif" alt="Dashboard designer — drag a chart from the palette, drop on canvas, edit properties, save" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The designer is an Angular component (&lt;code&gt;&amp;lt;wuic-designer&amp;gt;&lt;/code&gt;) that the framework opens when you click "Edit" on any dashboard route. It has three panels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Palette&lt;/strong&gt; on the left. A scrollable list of every component the runtime knows how to render — layout primitives (&lt;code&gt;TABLE&lt;/code&gt;, &lt;code&gt;TR&lt;/code&gt;, &lt;code&gt;TD&lt;/code&gt;, &lt;code&gt;DIV&lt;/code&gt;, &lt;code&gt;SPAN&lt;/code&gt;), data primitives (&lt;code&gt;DATASOURCE&lt;/code&gt;, &lt;code&gt;DATAREPEATER&lt;/code&gt;, &lt;code&gt;CHART&lt;/code&gt;, &lt;code&gt;MAP&lt;/code&gt;, &lt;code&gt;KANBAN&lt;/code&gt;, &lt;code&gt;SCHEDULER&lt;/code&gt;, &lt;code&gt;CAROUSEL&lt;/code&gt;, &lt;code&gt;SPREADSHEET&lt;/code&gt;, &lt;code&gt;TREE&lt;/code&gt;), and form/interaction primitives (buttons, links, dynamic templates). Each entry is a draggable card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canvas&lt;/strong&gt; in the middle. The live preview of the dashboard you're editing. You drop palette items into containers, drag existing nodes to reposition them, and the canvas re-renders the same component tree the runtime would render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Property panel&lt;/strong&gt; on the right. When a node is selected, this panel exposes its &lt;code&gt;inputs&lt;/code&gt; — every &lt;code&gt;@Input()&lt;/code&gt; the underlying Angular component declares. Edit a property, the canvas updates in place.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no compile step between "drag" and "see". The designer renders with the same components the runtime uses, so what you see in design mode is what your users see in view mode.&lt;/p&gt;

&lt;p&gt;A few of the affordances that took the most iteration:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Double-click on a palette item&lt;/strong&gt; drops it at the canvas root. Useful when you're starting from scratch and don't want to hunt for the right drop target.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right-click on a canvas node&lt;/strong&gt; opens a context menu with copy/paste/delete/wrap-in-container. Copy-paste serializes the subtree to clipboard JSON; pasting deserializes it back, useful for moving a whole tile across boards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undo/redo&lt;/strong&gt; with a per-edit history stack. Each property change, drop, or delete is one history entry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Save&lt;/strong&gt; does two writes: the canvas state goes to &lt;code&gt;dom_board.boardcontent&lt;/code&gt; as JSON, and any CSS edits go to &lt;code&gt;dom_board_sheet&lt;/code&gt; rows (more on that below).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's no "design mode JSON" you can edit by hand from inside the designer — but you can open the JSON via a different route and edit it externally if you want. The designer is the recommended tool because the JSON is large.&lt;/p&gt;

&lt;h2&gt;
  
  
  The palette
&lt;/h2&gt;

&lt;p&gt;Components are registered in the palette by registry, not hard-coded. Each component declares:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;componentType&lt;/code&gt; string (&lt;code&gt;CHART&lt;/code&gt;, &lt;code&gt;LIST&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;a default &lt;code&gt;inputs&lt;/code&gt; object (so a dropped component shows sensible defaults)&lt;/li&gt;
&lt;li&gt;an icon (PrimeIcons class) for the palette card&lt;/li&gt;
&lt;li&gt;a category (&lt;code&gt;Layout&lt;/code&gt;, &lt;code&gt;Data&lt;/code&gt;, &lt;code&gt;Form&lt;/code&gt;, &lt;code&gt;Custom&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;an optional drop-target predicate (e.g. a &lt;code&gt;TR&lt;/code&gt; only accepts &lt;code&gt;TD&lt;/code&gt; children)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So when you &lt;code&gt;npm install&lt;/code&gt; a host that registers a custom Angular widget — say, a colour picker or a map-with-clusters — it shows up in the palette automatically. The dashboards built on top can include it the same way they include the built-in &lt;code&gt;CHART&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;We didn't ship a fixed palette and a separate "extension point". The palette IS the extension point: register a component, it's there for the designer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in &lt;code&gt;dom_board&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The schema is intentionally small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dom_board&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id1&lt;/span&gt;            &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;boardroute&lt;/span&gt;     &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- the route the runtime opens&lt;/span&gt;
    &lt;span class="n"&gt;boarddes&lt;/span&gt;       &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;-- friendly label&lt;/span&gt;
    &lt;span class="n"&gt;boardcontent&lt;/span&gt;   &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;    &lt;span class="c1"&gt;-- the JSON the designer writes&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;boardroute&lt;/code&gt; matches a registered Angular route (e.g. &lt;code&gt;crm_home&lt;/code&gt;, &lt;code&gt;sasa/dashboard&lt;/code&gt;). When the user navigates there, the runtime fetches the row, parses &lt;code&gt;boardcontent&lt;/code&gt;, and builds the component tree.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;boarddes&lt;/code&gt; is a label that shows up in the dashboard picker. Inconsequential.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;boardcontent&lt;/code&gt; is the entire dashboard, serialized.&lt;/p&gt;

&lt;h2&gt;
  
  
  The JSON shape
&lt;/h2&gt;

&lt;p&gt;The serialized component tree mirrors the canvas. Each node has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a &lt;code&gt;componentType&lt;/code&gt; (&lt;code&gt;TABLE&lt;/code&gt;, &lt;code&gt;TR&lt;/code&gt;, &lt;code&gt;TD&lt;/code&gt;, &lt;code&gt;DIV&lt;/code&gt;, &lt;code&gt;SPAN&lt;/code&gt;, &lt;code&gt;DATASOURCE&lt;/code&gt;, &lt;code&gt;DATAREPEATER&lt;/code&gt;, &lt;code&gt;CHART&lt;/code&gt;, etc.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;inputs&lt;/code&gt; (the @Input bindings — anything the property panel could edit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;components&lt;/code&gt; (children, for layout containers)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;componentRows&lt;/code&gt; / &lt;code&gt;componentCells&lt;/code&gt; (specifically for &lt;code&gt;TABLE&lt;/code&gt;/&lt;code&gt;TR&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cssClass&lt;/code&gt; and &lt;code&gt;cssStyle&lt;/code&gt; if you applied per-node styling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's deliberately HTML-ish. The runtime doesn't try to invent its own grid system; it leans on familiar containers because browsers already know how to render them at 60fps, and the JSON maps 1:1 to the DOM the designer shows.&lt;/p&gt;

&lt;p&gt;The actual &lt;code&gt;inputs&lt;/code&gt; set per component type can be large — a &lt;code&gt;CHART&lt;/code&gt; has dozens of options (axis labels, palette, legend position, tooltip format, click handler, animation). The designer's property panel filters to the meaningful ones; the JSON keeps everything you customised and omits defaults.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DATASOURCE → consumer convention
&lt;/h2&gt;

&lt;p&gt;The thing that makes a dashboard JSON different from a "static page" JSON is the &lt;strong&gt;datasource pairing&lt;/strong&gt;. Each chart, list, map, or kanban comes in two pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"componentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DATASOURCE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"route"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"crm_opportunities_by_stage"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"autoload"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"uniqueName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ds_opportunities_by_stage_1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metaInfo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tableMetadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"md_props_bag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{...}"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"columnMetadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;/*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;per-column&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;metadata&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;route&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;*/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"componentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CHART"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"datasourceUniqueName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ds_opportunities_by_stage_1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"chartType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bar"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"labelField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stage_name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"valueField"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"deal_count"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first emits data, the second consumes it. They're paired by &lt;code&gt;uniqueName&lt;/code&gt; ↔ &lt;code&gt;datasourceUniqueName&lt;/code&gt;. This is the same convention used in the rest of WUIC — list pages, edit forms, chart pages — so the designer didn't invent a new contract.&lt;/p&gt;

&lt;p&gt;What's important here: the &lt;code&gt;route&lt;/code&gt; on the datasource points to a &lt;strong&gt;registered metadata route&lt;/strong&gt;. The runtime resolves that route against &lt;code&gt;_metadati__tabelle.mdroutename&lt;/code&gt;, loads the table/column metadata, calls the auto-generated CRUD endpoint, and feeds the result to the consumer. The chart didn't have to know SQL. The route is the abstraction.&lt;/p&gt;

&lt;p&gt;In the designer UI, dropping a &lt;code&gt;DATASOURCE&lt;/code&gt; opens a route picker (autocomplete over all registered routes), and dropping a &lt;code&gt;CHART&lt;/code&gt; or &lt;code&gt;LIST&lt;/code&gt; after one auto-binds via &lt;code&gt;uniqueName&lt;/code&gt;. You can also paste an already-bound pair as a unit.&lt;/p&gt;

&lt;h2&gt;
  
  
  CSS support: &lt;code&gt;dom_board_sheet&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Fdesigner__designer-css__desktop.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fwuic-framework.com%2Fassets%2Fwuic-framework-docs%2Fscreenshots%2Fdesigner__designer-css__desktop.gif" alt="Designer CSS panel — attach a stylesheet to the current board, edit in-browser, see the canvas re-render live" width="" height=""&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Out of the box, dashboards inherit the global app theme. Often that's enough. When it's not — a "KPI tile that flips red when negative", a "card that uses the customer's brand colour", a per-board print stylesheet — we need a place to put CSS that's scoped to the board and survives runtime upgrades.&lt;/p&gt;

&lt;p&gt;The schema:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;dom_board_sheet&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;id1&lt;/span&gt;            &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;IDENTITY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dom_boardid&lt;/span&gt;    &lt;span class="nb"&gt;INT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;        &lt;span class="c1"&gt;-- FK to dom_board.id1&lt;/span&gt;
    &lt;span class="n"&gt;sheet_path1&lt;/span&gt;    &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;   &lt;span class="c1"&gt;-- path to a .css file shipped with the app&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per stylesheet attached to a board. A board can have many sheets (a base one + a print one + a customer-brand one, say). At dashboard load, the runtime resolves each &lt;code&gt;sheet_path1&lt;/code&gt; against the app's static asset folder, injects &lt;code&gt;&amp;lt;link rel="stylesheet"&amp;gt;&lt;/code&gt; tags into the head with a board-scope marker, and unmounts them when the user leaves the route.&lt;/p&gt;

&lt;p&gt;In the designer, the stylesheet panel lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Attach&lt;/strong&gt; an existing &lt;code&gt;.css&lt;/code&gt; file from the app's assets to the current board (creates a &lt;code&gt;dom_board_sheet&lt;/code&gt; row pointing at it).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edit&lt;/strong&gt; an attached file in an in-designer text editor. Save writes to disk + invalidates the runtime cache so the canvas re-renders with the new styles immediately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply&lt;/strong&gt; classes to nodes via the property panel — type a class name in &lt;code&gt;cssClass&lt;/code&gt;, the canvas reflects it, and the JSON records it on the node.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is intentionally file-based, not embedded in JSON. CSS is text. It belongs in &lt;code&gt;.css&lt;/code&gt; files in source control. The DB just tells the runtime which files to load for which board.&lt;/p&gt;

&lt;p&gt;The per-board scope is a CSS class wrapper — every component the designer emits gets a &lt;code&gt;data-board-id="&amp;lt;id&amp;gt;"&lt;/code&gt; attribute, and the linked stylesheet authors are expected to scope selectors with &lt;code&gt;[data-board-id="42"] .my-class&lt;/code&gt;. We didn't build runtime CSS-in-JS or shadow DOM scoping; the convention is plain CSS authoring discipline. For most internal apps that's been enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off: Angular template strings inside JSON
&lt;/h2&gt;

&lt;p&gt;There's one ugly part. Some component nodes — specifically the ones that need a binding expression that the designer didn't have a UI for — carry a &lt;code&gt;tag&lt;/code&gt; field with an &lt;strong&gt;Angular template string&lt;/strong&gt; inside the JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"componentType"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SPAN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;span [innerText]=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;computeKpi(record.amount, record.tax)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt; [style.color]=&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;record.amount &amp;gt; 0 ? '#10b981' : '#ef4444'&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;&amp;gt;&amp;lt;/span&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The runtime compiles that &lt;code&gt;tag&lt;/code&gt; string into a real Angular component (via &lt;code&gt;ɵcompileComponent&lt;/code&gt;) at dashboard-load time and slots it in. This is how the designer lets a power-user write a KPI tile with custom formatting without writing TypeScript.&lt;/p&gt;

&lt;p&gt;Trade-off:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pro:&lt;/strong&gt; any binding expression Angular supports works inside the dashboard, without us having to invent a domain-specific binding language.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Con:&lt;/strong&gt; the JSON is no longer just data. A typo in a binding string fails at dashboard-load, not at save-time. We can't fully validate &lt;code&gt;boardcontent&lt;/code&gt; with a JSON schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We mitigated this with a designer-side parser that catches the most common errors (unclosed braces, unknown variable names) before save, and a runtime fallback that logs the broken binding and renders the rest of the dashboard rather than crashing. But it's a real downside — if you want JSON purity, this isn't it.&lt;/p&gt;

&lt;p&gt;For us the alternative was worse: invent our own expression language, document it, debug it, version it. Reusing Angular's binding syntax meant the designer's mental model is the same as the framework's mental model, and we didn't add a new layer of indirection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What stays metadata-driven
&lt;/h2&gt;

&lt;p&gt;Even with the template-string escape hatch, the data flowing into the dashboard is metadata-driven all the way down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data source route&lt;/strong&gt; → resolves to &lt;code&gt;_metadati__tabelle.mdroutename&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column metadata&lt;/strong&gt; → comes from &lt;code&gt;_metadati__colonne&lt;/code&gt;, includes formatting, lookup resolution, validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt; → checked against &lt;code&gt;_mtdt__tnt__trzzzioni__tabelle&lt;/code&gt; per requesting user/role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Translations&lt;/strong&gt; → labels resolved via the &lt;code&gt;_wuic_translations&lt;/code&gt; table on render.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What you wrote into the dashboard is &lt;em&gt;what to show&lt;/em&gt; (which datasources, what archetype, what styling). What you didn't write is &lt;em&gt;how to fetch it&lt;/em&gt; — the framework figures that out from the route's metadata. Change the underlying SQL view, invalidate the metadata cache, the dashboard picks up the new shape without re-saving.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the designer is the wrong tool
&lt;/h2&gt;

&lt;p&gt;If your "dashboard" is really an app page with bespoke layout, custom interaction patterns, and three integrated forms — write it in Angular. The designer is for &lt;strong&gt;data presentation&lt;/strong&gt;: a board of charts, KPI tiles, lists, maps, calendars, with light interactivity. It's not a page builder for arbitrary UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt; ships with a few dashboards under the "Home" menu. Open one, then click "Edit" in the top-right of any board — the designer opens read-write. Drag a chart from the palette, change its &lt;code&gt;chartType&lt;/code&gt; in the property panel, attach a CSS sheet. Save. The change persists for 24 hours, then the nightly reset wipes it back to the seed.&lt;/p&gt;

&lt;p&gt;If you want to read the source: the designer is &lt;code&gt;designer.component.ts&lt;/code&gt;, the runtime renderer is &lt;code&gt;boardcontent-runtime.component.ts&lt;/code&gt;, the palette registry is &lt;code&gt;designer-palette-registry.ts&lt;/code&gt;, and the stylesheet injection lives in &lt;code&gt;dom-board-sheet-loader.service.ts&lt;/code&gt;. The codebase chatbot (&lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;RAG post&lt;/a&gt;) can locate each one faster than the file tree can.&lt;/p&gt;

&lt;p&gt;Next in this series: &lt;strong&gt;report generation&lt;/strong&gt; — how a SQL view becomes a &lt;code&gt;.mrt&lt;/code&gt; report with no per-route code, and how the report designer integrates with the same metadata schema the dashboards use. Subscribe via the RSS feed.&lt;/p&gt;

</description>
      <category>dashboard</category>
      <category>designer</category>
      <category>lowcode</category>
      <category>angular</category>
    </item>
    <item>
      <title>WUIC on Linux, natively: one binary, four DBMS choices, one shell installer</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Mon, 01 Jun 2026 07:29:32 +0000</pubDate>
      <link>https://dev.to/wuicframework/wuic-on-linux-natively-one-binary-four-dbms-choices-one-shell-installer-1mg7</link>
      <guid>https://dev.to/wuicframework/wuic-on-linux-natively-one-binary-four-dbms-choices-one-shell-installer-1mg7</guid>
      <description>&lt;p&gt;We shipped WUIC originally as a Windows / IIS stack: SQL Server, ASP.NET Core under the AspNetCoreModuleV2 hosting bundle, the whole thing managed by IIS. That's still a supported topology and the one most existing customers run. But .NET 10 is cross-platform, our metadata layer talks to four different DBMS providers, and increasingly the customers asking to evaluate WUIC don't have a Windows server lying around. So we sat down and made Linux a first-class target.&lt;/p&gt;

&lt;p&gt;Today there's a single shell one-liner that installs WUIC on a stock Ubuntu 22.04 box, with your choice of database. This post walks through what the installer does, what each DBMS option means in practice on Linux, and what's deliberately still Windows-only.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-liner
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://wuic-framework.com/install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;bash &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--dbms&lt;/span&gt; mssql &lt;span class="nt"&gt;--admin-password&lt;/span&gt; &lt;span class="s1"&gt;'YourS3cret!'&lt;/span&gt; &lt;span class="nt"&gt;--hostname&lt;/span&gt; app.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole install. End-to-end it runs about 15–25 minutes on a 4-vCPU box, most of which is &lt;code&gt;apt install&lt;/code&gt; for the .NET runtime and (optionally) &lt;code&gt;pip install&lt;/code&gt; for the Python RAG stack. Everything else is configuration.&lt;/p&gt;

&lt;p&gt;When the script exits, you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The .NET app running under &lt;strong&gt;Kestrel&lt;/strong&gt; at &lt;code&gt;127.0.0.1:5000&lt;/code&gt;, managed by &lt;code&gt;systemd&lt;/code&gt; (&lt;code&gt;wuic-core.service&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Your chosen &lt;strong&gt;DBMS&lt;/strong&gt; listening on its loopback port, managed by its own systemd unit.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;Python RAG server&lt;/strong&gt; (FastAPI + uvicorn + a LoRA-tuned reranker) at &lt;code&gt;127.0.0.1:8765&lt;/code&gt;, managed by &lt;code&gt;wuic-rag.service&lt;/code&gt;. Skipped if you pass &lt;code&gt;--no-rag&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nginx&lt;/strong&gt; on &lt;code&gt;:80&lt;/code&gt; (and &lt;code&gt;:443&lt;/code&gt; with &lt;code&gt;--with-tls&lt;/code&gt;), serving the Angular static assets directly and proxying &lt;code&gt;/api/&lt;/code&gt;, &lt;code&gt;/hubs/&lt;/code&gt;, &lt;code&gt;/rag/&lt;/code&gt; to the right backend.&lt;/li&gt;
&lt;li&gt;Configuration in &lt;code&gt;/etc/wuiccore/secrets.env&lt;/code&gt;, loaded by systemd at unit start. The &lt;code&gt;appsettings.json&lt;/code&gt; on disk stays untouched — Linux-specific values are injected via env vars.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Idempotent. Re-running the one-liner on a finished box skips the already-done steps and updates only what changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's in the box
&lt;/h2&gt;

&lt;p&gt;The installer is structured as numbered bash scripts under &lt;code&gt;scripts/linux/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;00-prereqs.sh                 OS package guardrails (apt update, curl, gnupg…)
10-install-dotnet.sh          .NET 10 runtime + ASP.NET Core
20-install-mssql.sh           SQL Server 2022 Express
21-install-mysql.sh           MySQL 8
22-write-secrets-profiles.sh  Generate /etc/wuiccore/secrets.{env,master}
23-install-postgres.sh        PostgreSQL 16 (PGDG)
24-install-oracle.sh          Oracle Database Free 23ai (Docker container)
30-bootstrap-databases.sh     Restore/create the WUIC databases (MSSQL)
31-bootstrap-mysql-databases.sh
33-bootstrap-postgres-databases.sh
34-bootstrap-oracle-databases.sh
35-provision-auth-users.sh    Seed the admin user with --admin-password
40-install-python-rag.sh      torch CPU + transformers + bge-m3 + LoRA adapter
50-publish-app.sh             dotnet publish + drop the artifact under /opt/wuiccore/
60-systemd-units.sh           wuic-core.service, wuic-rag.service, enable + start
70-nginx.sh                   vhost + reverse proxy + (optional) certbot TLS
80-smoke-test.sh              curl /api/health, dotnet --info, mssql/mysql -Q "SELECT 1"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;install.sh&lt;/code&gt; (the one-liner) downloads a precompiled tarball, untars to &lt;code&gt;/opt/wuiccore/&lt;/code&gt;, and runs the scripts above in order, gated by the flags you passed. The numbered scripts are also runnable individually, which is useful when you want to install only the DB (for a dev workstation) or only refresh the .NET app without touching the database (&lt;code&gt;50&lt;/code&gt; + &lt;code&gt;60&lt;/code&gt; re-run).&lt;/p&gt;

&lt;h2&gt;
  
  
  The four DBMS options
&lt;/h2&gt;

&lt;p&gt;We do &lt;strong&gt;not&lt;/strong&gt; ship a different .NET build per DBMS. The same &lt;code&gt;WuicCore.dll&lt;/code&gt; runs on every variant. MSSQL is the baseline and is built into the main assembly directly; for MySQL, PostgreSQL or Oracle the runtime additionally loads a satellite provider DLL (&lt;code&gt;mysql.dll&lt;/code&gt;, &lt;code&gt;postgresql.dll&lt;/code&gt; or &lt;code&gt;oracle.dll&lt;/code&gt;) that sits next to &lt;code&gt;WuicCore.dll&lt;/code&gt; in &lt;code&gt;bin/&lt;/code&gt; and is picked up at startup based on the &lt;code&gt;dbms&lt;/code&gt; setting in &lt;code&gt;appsettings.json&lt;/code&gt; (&lt;code&gt;mssql&lt;/code&gt; / &lt;code&gt;mysql&lt;/code&gt; / &lt;code&gt;postgresql&lt;/code&gt; / &lt;code&gt;oracle&lt;/code&gt;). On Linux, the installer drops the right satellite DLL into &lt;code&gt;/opt/wuiccore/app/bin/&lt;/code&gt; based on &lt;code&gt;--dbms&lt;/code&gt;. All four are exercised by the same test suite (~600 metadata-driven tests running against the live backend), so a passing run on one DBMS exercises the same call paths as a passing run on the others. Here's what each one actually means:&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--dbms mssql&lt;/code&gt; (SQL Server 2022 Express on Linux)
&lt;/h3&gt;

&lt;p&gt;Microsoft ships SQL Server 2022 native for Ubuntu 22.04. The installer adds the Microsoft apt repo, installs &lt;code&gt;mssql-server&lt;/code&gt;, runs &lt;code&gt;mssql-conf setup&lt;/code&gt; non-interactively with the auto-generated SA password (stored in &lt;code&gt;/etc/wuiccore/secrets.master&lt;/code&gt;, mode 0600), binds the listener to &lt;code&gt;127.0.0.1:1433&lt;/code&gt;, and loads the seeded databases via either &lt;code&gt;RESTORE DATABASE&lt;/code&gt; from the &lt;code&gt;.bak&lt;/code&gt; (~2s) or the streaming &lt;code&gt;.sql&lt;/code&gt; (~25 min) depending on the tarball variant you downloaded.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--dbms mysql&lt;/code&gt; (MySQL 8 on Linux)
&lt;/h3&gt;

&lt;p&gt;The path for cost-sensitive cloud deploys (cheap managed MySQL is everywhere). The installer adds Ubuntu's stock MySQL 8 (&lt;code&gt;apt install mysql-server&lt;/code&gt;), binds to &lt;code&gt;127.0.0.1:3306&lt;/code&gt;, runs &lt;code&gt;mysql_secure_installation&lt;/code&gt; equivalents non-interactively, and loads the schema from &lt;code&gt;dbms/scripts/first-run/*.mysql.sql&lt;/code&gt;. The provider is &lt;code&gt;mysql.dll&lt;/code&gt;, loaded by the .NET runtime via the same hot-swap mechanism the Windows host uses. The metadata layer, the auto-generated CRUD endpoints and the scaffolder don't have per-DBMS branches — they emit ANSI SQL where possible and call into the provider for the edge cases (paging keywords, JSON column read/write, MERGE semantics).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--dbms postgres&lt;/code&gt; (PostgreSQL 16 via PGDG)
&lt;/h3&gt;

&lt;p&gt;Same provider mechanism (&lt;code&gt;postgresql.dll&lt;/code&gt;). &lt;code&gt;23-install-postgres.sh&lt;/code&gt; adds the PGDG repo (we need ≥16 because the seeded &lt;code&gt;pg_dump&lt;/code&gt; files were generated against 16.x), installs &lt;code&gt;postgresql-16&lt;/code&gt;, binds to &lt;code&gt;127.0.0.1:5432&lt;/code&gt;, and loads the seeded schemas from &lt;code&gt;dbms/scripts/first-run/*.postgres.sql&lt;/code&gt;. End state: systemd-managed Postgres, secrets in &lt;code&gt;/etc/wuiccore/secrets.env&lt;/code&gt;, the .NET app picks up &lt;code&gt;dbms=postgresql&lt;/code&gt; from the env var and loads the right provider.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;--dbms oracle&lt;/code&gt; (Oracle Free 23ai via Docker)
&lt;/h3&gt;

&lt;p&gt;Oracle is the awkward one. Oracle Database Free 23ai ships as an &lt;code&gt;.rpm&lt;/code&gt; targeting OL/RHEL; running it natively on Ubuntu requires &lt;code&gt;alien&lt;/code&gt; conversion plus glibc/kernel workarounds that Oracle doesn't officially support. So on Linux we run it in Docker, using the community-maintained &lt;code&gt;gvenzl/oracle-free:23-slim&lt;/code&gt; image (Apache-2.0 wrapper around the official Oracle Free 23ai binaries, distribution governed by Oracle's Free Use License). &lt;code&gt;24-install-oracle.sh&lt;/code&gt; installs Docker if not present, pulls the image, runs the container bound to &lt;code&gt;127.0.0.1:1521&lt;/code&gt;, generates the SYSTEM/PDB passwords, and waits for &lt;code&gt;SELECT 1 FROM DUAL&lt;/code&gt; to succeed. &lt;code&gt;34-bootstrap-oracle-databases.sh&lt;/code&gt; then loads the seeded schemas. The Windows dev side standardized on the same image (the regeneration scripts under &lt;code&gt;scripts/regen-firstrun-oracle.ps1&lt;/code&gt; dump from &lt;code&gt;localhost:1521/FREEPDB1&lt;/code&gt; against it), so the schema we ship is the schema we develop against.&lt;/p&gt;

&lt;h3&gt;
  
  
  Combinations
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;install-all.sh&lt;/code&gt; also accepts &lt;code&gt;--dbms both&lt;/code&gt; (mssql + mysql side-by-side) and &lt;code&gt;--dbms all&lt;/code&gt; (all four side-by-side). Useful when you want one box that can host every variant and switch the active one by env var, typically a test rig.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually the same across all four
&lt;/h2&gt;

&lt;p&gt;The whole point of the provider-drop-in story is that the application layer doesn't care which DBMS is underneath:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRUD endpoints&lt;/strong&gt; are auto-generated from the metadata route once. The same &lt;code&gt;GET /api/Meta/AsmxProxy/&amp;lt;route&amp;gt;.crudRead&lt;/code&gt; works on every DBMS; the provider translates to whatever SQL dialect is appropriate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The scaffolder&lt;/strong&gt; reads &lt;code&gt;INFORMATION_SCHEMA&lt;/code&gt; (or the Oracle / Postgres equivalent) and produces the same metadata rows. A table scaffolded on Postgres has the same metadata shape as the same table on MSSQL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metadata cache, the runtime templates, the designer, the dashboard renderer&lt;/strong&gt; — these don't know which DB you picked. They read &lt;code&gt;_metadati__*&lt;/code&gt; and run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Angular bundle&lt;/strong&gt; is identical. Same &lt;code&gt;WuicSite/wwwroot/&lt;/code&gt; artifact regardless of backend DBMS.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What does change per-DBMS:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;satellite provider DLL&lt;/strong&gt; loaded alongside &lt;code&gt;WuicCore.dll&lt;/code&gt; for non-MSSQL backends (and the connection string format in &lt;code&gt;appsettings.json&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;seed SQL&lt;/strong&gt; loaded at first-run: &lt;code&gt;tutorial-data.mssql.sql&lt;/code&gt; vs &lt;code&gt;.mysql.sql&lt;/code&gt; vs &lt;code&gt;.postgres.sql&lt;/code&gt; vs &lt;code&gt;.oracle.sql&lt;/code&gt;, generated by an internal &lt;code&gt;dotnet publish -GenerateSqlScripts&lt;/code&gt; script that dumps a reference instance per DBMS. Same data, four dialects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small set of SQL-builder code paths&lt;/strong&gt; for things the DBMSs handle differently (paging keywords, &lt;code&gt;MERGE&lt;/code&gt; vs &lt;code&gt;ON CONFLICT&lt;/code&gt;, JSON column read/write). These live in the main assembly for MSSQL and in the satellite DLL for the other three — you don't see them from the app layer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's deliberately still Windows-only
&lt;/h2&gt;

&lt;p&gt;A couple of things didn't come over and probably never will:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IIS hosting.&lt;/strong&gt; On Linux we use Kestrel + nginx instead. Same .NET app under the hood, different process manager. IIS-specific tooling (&lt;code&gt;appcmd&lt;/code&gt;, the IIS Manager UI, WinRM remote management) doesn't apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SQL Server Management Studio integration helpers.&lt;/strong&gt; SSMS is Windows-only. The Linux installer ships &lt;code&gt;mssql-tools18&lt;/code&gt; (sqlcmd, bcp) for the same CLI workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tarball variants
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;install.sh&lt;/code&gt; one-liner downloads one of three Linux tarballs, depending on what you ask for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wuic-framework-v&amp;lt;ver&amp;gt;-linux-x64.tar.gz&lt;/code&gt; (runtime, ~600 MB) — pre-compiled &lt;code&gt;dotnet publish&lt;/code&gt; artifact + Angular bundle + linux installer scripts + DB seed SQL. Default for the operator-running-a-service path.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;wuic-framework-v&amp;lt;ver&amp;gt;-linux-x64-src.tar.gz&lt;/code&gt; (source, ~80 MB) — the &lt;code&gt;.cs&lt;/code&gt; and &lt;code&gt;.ts&lt;/code&gt; source tree, for developers cloning to build locally. Defaults extract to &lt;code&gt;/opt/wuic-src/&lt;/code&gt; owned by &lt;code&gt;${SUDO_USER}&lt;/code&gt; so the dev can &lt;code&gt;dotnet build&lt;/code&gt; without sudo.&lt;/li&gt;
&lt;li&gt;Both, if you don't pass &lt;code&gt;--demo-only&lt;/code&gt; or &lt;code&gt;--src-only&lt;/code&gt;. Useful on a single-box dev environment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either variant uses the same numbered install scripts and the same systemd units. The difference is whether &lt;code&gt;/opt/wuiccore/app/&lt;/code&gt; is a pre-built artifact or a &lt;code&gt;dotnet run --project /opt/wuic-src/...&lt;/code&gt; invocation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Linux is the wrong call
&lt;/h2&gt;

&lt;p&gt;If your shop already has a Windows AD domain and an IIS production environment with PowerShell DSC / Ansible Windows playbooks already configured, the IIS deploy is still the path of least resistance. Linux is the right answer for fresh deploys, cost-sensitive cloud (no Windows licensing), or environments where ops already lives in Linux land. We don't think there's a wrong choice — the application code is the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;public demo&lt;/a&gt; currently runs the Windows/IIS topology. The Linux deploy is what you'd run on your own server. The one-liner is hosted at &lt;a href="https://wuic-framework.com/install.sh" rel="noopener noreferrer"&gt;&lt;code&gt;install.sh&lt;/code&gt;&lt;/a&gt; — read it before piping to bash if that makes you uncomfortable (it's a 700-line script, mostly comments). The tarballs are listed on the &lt;a href="https://wuic-framework.com/downloads" rel="noopener noreferrer"&gt;downloads page&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Linux installer source tree lives in &lt;code&gt;scripts/linux/&lt;/code&gt; if you want to read each step. The codebase chatbot (&lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;RAG post&lt;/a&gt;) can find any specific step by description if you don't want to navigate manually.&lt;/p&gt;

&lt;p&gt;Next in this batch of posts: the &lt;strong&gt;wizard architecture&lt;/strong&gt; — multi-step forms with conditional branches, all driven by metadata. Subscribe via the RSS feed.&lt;/p&gt;

</description>
      <category>linux</category>
      <category>dotnet</category>
      <category>database</category>
      <category>mysql</category>
    </item>
    <item>
      <title>Why we built a metadata-driven Angular framework instead of using Retool</title>
      <dc:creator>Wuic Framework</dc:creator>
      <pubDate>Sun, 31 May 2026 14:39:07 +0000</pubDate>
      <link>https://dev.to/wuicframework/why-we-built-a-metadata-driven-angular-framework-instead-of-using-retool-2m1l</link>
      <guid>https://dev.to/wuicframework/why-we-built-a-metadata-driven-angular-framework-instead-of-using-retool-2m1l</guid>
      <description>&lt;p&gt;When you start a B2B project and someone says &lt;em&gt;"we'll use Retool, it's faster"&lt;/em&gt;, they're usually right. For the first three internal tools, they always are. Drag-and-drop panels over a Postgres database, an SSO connector, a couple of approval flows — done in two afternoons. We were that team. For a year and a half, we shipped real value through Retool dashboards.&lt;/p&gt;

&lt;p&gt;This is the story of why we eventually stopped, and why we ended up building &lt;a href="https://wuic-framework.com/" rel="noopener noreferrer"&gt;WUIC&lt;/a&gt; — a closed-source, metadata-driven Angular framework — instead of switching to one of the obvious open-source alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The wall
&lt;/h2&gt;

&lt;p&gt;Three things happened in the same quarter that made Retool stop feeling like a good fit:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The dashboards became the product.&lt;/strong&gt; What started as internal admin panels grew into client-facing CRM screens. Customers saw them, customers complained about them. Suddenly we needed pixel-level control over forms, custom validation messages our designer cared about, dark mode that actually matched the rest of our brand, mobile views that didn't look like a Retool dashboard squashed into a phone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The bill scaled with users, not with value.&lt;/strong&gt; Retool's per-end-user pricing is generous when 8 people log in. It's painful when 800 do. Our usage curve was about to flip from "internal tools" to "operations platform served to the customer", and the math stopped working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. We hit the customisation cliff.&lt;/strong&gt; Once you have JS code in Retool that talks to JS code in Retool that talks to JS code in Retool, the cliff is real. You're writing a frontend inside a frontend, with no IDE that understands the bindings, no version control that diffs cleanly, no CI that runs. Code review becomes "look at this screenshot of the canvas". We've been there. It's not where you want to live.&lt;/p&gt;

&lt;p&gt;So we evaluated alternatives.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we considered
&lt;/h2&gt;

&lt;p&gt;The shortlist was the usual: &lt;strong&gt;&lt;a href="https://refine.dev" rel="noopener noreferrer"&gt;Refine&lt;/a&gt;&lt;/strong&gt; (React framework), &lt;strong&gt;&lt;a href="https://budibase.com" rel="noopener noreferrer"&gt;Budibase&lt;/a&gt;&lt;/strong&gt; (open-source low-code platform), &lt;strong&gt;&lt;a href="https://www.appsmith.com" rel="noopener noreferrer"&gt;AppSmith&lt;/a&gt;&lt;/strong&gt; (open-source Retool clone). Each had something we liked.&lt;/p&gt;

&lt;p&gt;Refine in particular is technically excellent — if your team is React-fluent and wants a hand-written admin UI with full code review, it's a great pick. The reason we passed wasn't the framework, it was the math: we'd estimated 18 months of full-time work to reach the feature surface our existing Retool screens already covered (workflow engine, report builder, dynamic permissions, multi-tenant audit log, mobile auto-layout). Eighteen months of building those wheels instead of the actual product.&lt;/p&gt;

&lt;p&gt;Budibase and AppSmith were closer to what we wanted in spirit — drag-and-drop, but self-hosted and open-source — but each had its own ecosystem to learn, and migrating data + auth + workflows from Retool to either of them was a non-trivial port. The cliff would just move; we'd hit it again at a different angle.&lt;/p&gt;

&lt;p&gt;We also considered &lt;strong&gt;&lt;a href="https://retool.com/pricing" rel="noopener noreferrer"&gt;just keep paying Retool more&lt;/a&gt;&lt;/strong&gt;. We'd be lying if we said we didn't crunch those numbers. The blocker there was less the price and more the customisation cliff: even with the Enterprise tier, we couldn't ship the UI quality our customers were starting to expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The insight
&lt;/h2&gt;

&lt;p&gt;Around that time we noticed something about our Retool screens. Almost every form, list and dashboard was &lt;em&gt;implicit metadata&lt;/em&gt;. The columns came from the database. The validation rules came from the column types and the business constraints. The buttons came from the route's permitted actions. The lookup widgets came from foreign keys.&lt;/p&gt;

&lt;p&gt;We were typing all this into Retool's UI by hand. Then typing similar things into our backend's input validation. Then typing similar things into our API documentation. Three places, slightly inconsistent, drifting over time.&lt;/p&gt;

&lt;p&gt;The thought was: &lt;strong&gt;what if the metadata was the source of truth, and the UI was a pure function of it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "low-code" — there's still real code to write, and we wanted that. &lt;em&gt;Less&lt;/em&gt; code. Specifically: zero hand-written boilerplate for the 80% of screens that are obvious from the schema, and full Angular freedom for the 20% that aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What WUIC actually is
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F56htv0z9epf3400rjbgx.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F56htv0z9epf3400rjbgx.gif" alt="WUIC theme switcher — same metadata, multiple visual themes, runtime switch" width="599" height="295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;WUIC is two databases worth of metadata + a runtime that turns it into a working Angular app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;routes table&lt;/strong&gt; describing every entity in your domain — name, table, default permissions, default form layout, anything that's true of the entity as a whole.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;columns table&lt;/strong&gt; describing every field — type, label, validation rules, lookup target, visibility per role, default styling, callbacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Given those, the runtime renders:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Auto-CRUD list pages with sort/filter/group/inline-edit out of the box.&lt;/li&gt;
&lt;li&gt;Edit forms with the right widget per type (text, lookup, file upload, rich text, date with locale formatting, …).&lt;/li&gt;
&lt;li&gt;A dashboard designer where you drag widgets onto a canvas, bind them to a route, save — the dashboard goes live.&lt;/li&gt;
&lt;li&gt;A report designer that produces PDF + Excel from the same metadata.&lt;/li&gt;
&lt;li&gt;A workflow engine where each step is a route, and the graph between them is more metadata.&lt;/li&gt;
&lt;li&gt;A mobile responsive layout that derives card stacks from the same column metadata that drives desktop tables.&lt;/li&gt;
&lt;li&gt;An in-product RAG chatbot that answers questions about the data + the framework itself (&lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;more on that in the next post&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can drop down to plain Angular components anywhere — &lt;code&gt;&amp;lt;wuic-list-grid&amp;gt;&lt;/code&gt; is a regular standalone component, you can wrap it, replace it, override its template. We deliberately kept the framework at "code-saver" level rather than "low-code platform". Less typing, full developer control.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's not in the box
&lt;/h2&gt;

&lt;p&gt;It's important to be honest about what &lt;em&gt;isn't&lt;/em&gt; a good fit for this approach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One-off internal panels with weird datasources.&lt;/strong&gt; If you need to wire a button on a dashboard to a Slack webhook, that's a Retool thing. WUIC assumes your data lives in SQL. We have integrations, but the framework's heart is database-driven.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams without a SQL expert.&lt;/strong&gt; Metadata is conceptually simple but it lives next to your schema. If nobody on the team is comfortable opening SQL Server Management Studio, the value proposition collapses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greenfield mobile-first products.&lt;/strong&gt; We do mobile, and it works, but a product where mobile is the primary surface should probably start mobile-first. WUIC starts desktop-first and reflows down.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 18 months in retrospect
&lt;/h2&gt;

&lt;p&gt;It's been roughly 18 months since we cut the first Retool screen. The trade-off, blunt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throughput on new screens&lt;/strong&gt; went up. The 80% of screens that are obvious from the schema take minutes, not days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throughput on the &lt;em&gt;first&lt;/em&gt; screen&lt;/strong&gt; went down. There's now a framework to learn, conventions to follow, metadata to populate. A non-developer can't ship in a single afternoon any more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customisation cliff&lt;/strong&gt; shifted from "Retool's JS sandbox" to "Angular and TypeScript". Higher floor, much higher ceiling. Code review works. Git diff works. CI works.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user cost&lt;/strong&gt; dropped to zero. Per-developer license, fixed annual. The math now scales.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're early-stage and doing 5 internal panels with 10 internal users, &lt;strong&gt;we'd still tell you to use Retool&lt;/strong&gt;. It really is faster. The break-even point in our case was around screen #15 + first customer-facing dashboard.&lt;/p&gt;

&lt;p&gt;If you're past that, and you want to learn about the alternatives we considered side-by-side — including how WUIC compares against Refine, Budibase, and AppSmith feature-by-feature — there's a &lt;a href="https://wuic-framework.com/comparison" rel="noopener noreferrer"&gt;comparison page&lt;/a&gt; for that, and the &lt;a href="https://wuic-framework.com/gallery" rel="noopener noreferrer"&gt;feature gallery&lt;/a&gt; shows the framework actually doing things. Or &lt;a href="https://wuic-framework.com/sandbox" rel="noopener noreferrer"&gt;skip the talking and try it&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The next post in this series digs into one specific feature — the &lt;a href="https://wuic-framework.com/blog/rag-chatbot-with-claude-and-bge-m3" rel="noopener noreferrer"&gt;in-product RAG chatbot&lt;/a&gt; — and why we ended up building one from scratch when "just integrate ChatGPT" would have shipped in a week. Spoiler: it's about citations.&lt;/p&gt;

</description>
      <category>angular</category>
      <category>lowcode</category>
      <category>retool</category>
      <category>framework</category>
    </item>
  </channel>
</rss>
