Skip to content

Commit 97dd22e

Browse files
authored
Dashboard design improvements (#42)
1 parent 4d0c523 commit 97dd22e

8 files changed

Lines changed: 635 additions & 136 deletions

File tree

agent/skills/dashboard-design/SKILL.md

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
name: dashboard-design
33
description: Design and code a self-contained HTML dashboard for DeepSQL — ground on the schema, verify SQL, then write a beautiful single-file dashboard that loads data via the deepsql.query bridge.
4-
version: 2.0.0
4+
version: 2.1.0
55
platforms: [linux, macos, windows]
66
metadata:
77
hermes:
@@ -23,16 +23,26 @@ await deepsql.query("SELECT ...") // -> { columns: string[], rows: any[]
2323
deepsql.ready(fn) // runs fn() once the bridge is live (use this to kick off loading)
2424

2525
// Charts — ALWAYS use these instead of hand-writing SVG. Built-in hover tooltips
26-
// (show the value on mouse-over), number formatting, sparse axis labels, and a
27-
// graceful "No data" empty state. Pass the deepsql.query result straight in, or
28-
// [{label,value}] / [[label,value]]. First column = label, second = value (or
29-
// opts.labelKey/valueKey). opts: { valueFormat(fn), height, color, emptyText }.
26+
// (show the value on mouse-over), number formatting, sparse axis labels, a
27+
// graceful "No data" empty state, and a corner expand button that opens the
28+
// same chart larger in an overlay — all automatic, nothing to wire yourself.
29+
// Pass the deepsql.query result straight in, or [{label,value}] / [[label,value]].
30+
// First column = label, second = value (or opts.labelKey/valueKey).
31+
// opts: { valueFormat(fn), height, color, emptyText, title }.
3032
deepsql.charts.bar(elOrSelector, data, opts) // rankings, counts by day
3133
deepsql.charts.line(elOrSelector, data, opts) // trends over time (area+line)
3234
deepsql.charts.donut(elOrSelector, data, opts) // share/composition (with legend + %)
3335
deepsql.charts.format(n) // human number formatter
3436
```
3537

38+
**Chart sizing and the expand control are handled by the runtime — do not build your own.**
39+
Height is fixed regardless of container width (a chart in a wide card never balloons), and every
40+
chart already gets a corner "expand" button that opens a larger re-render in an overlay — this is
41+
exactly the kind of per-chart chrome that's tempting to hand-roll and easy to get inconsistent
42+
across widgets, so it's built into `deepsql.charts.*` once instead. Pass `opts.title` (the chart's
43+
plain-business-language heading) so the expanded overlay has something to show as its title — do
44+
not add your own zoom/expand/fullscreen button, modal, or lightbox; one already exists per chart.
45+
3646
Hard rules:
3747
- Inline everything — one `<style>`, one or more `<script>`. **No external URLs, CDNs, fonts, or images** (blocked by CSP) and **no `fetch()`/XHR/WebSocket** — data comes only from `deepsql.query`.
3848
- Never hardcode result data. Query live on load, and re-query when a control changes.
@@ -52,6 +62,9 @@ Hard rules:
5262
- No `undefined` / `null` / `NaN` can reach the screen — every injected value is guarded with a fallback. Pay special attention to KPI sub-labels and any computed % (e.g. a "top source share" caption).
5363
- Every explicit user ask from the intent checklist is present and wired (controls default correctly and re-query on change).
5464
- No table/column/SQL/connection-id text is visible anywhere.
65+
- No AI-slop pattern from the section below is present (gradient background/hero, emoji-as-icon,
66+
decorative blobs/glassmorphism, uniform shadows, off-scale spacing/type, more than one accented
67+
"hero" card, hand-rolled chart colors or expand/zoom controls).
5568
A dashboard that renders with a blank chart or an "undefined" label is a failed build — catch it here.
5669

5770
## NEVER expose internals (security + UX — non-negotiable)
@@ -80,6 +93,45 @@ Rules:
8093
- Numbers formatted for humans (`deepsql.charts.format(n)` / thousands separators; currency symbol from the business rule) — never raw.
8194
- **Never render `undefined`, `null`, or `NaN`.** Guard every value you inject into the DOM (`v == null ? '—' : v`); a KPI sub-line/label with no value must fall back to a dash or be omitted — not the literal text "undefined".
8295

96+
## Avoid AI-slop patterns (named, so you can catch yourself)
97+
98+
These are the specific tells that make a generated dashboard look generated instead of
99+
designed. Each one is easy to reach for by default — that's exactly why it needs to be named
100+
and ruled out explicitly, not left to taste.
101+
102+
- **No default purple/blue/pink gradient backgrounds.** A `linear-gradient(135deg, #667eea, #764ba2)`-style
103+
hero band, header, or card is the single most recognizable AI-generated-UI signature. `--ds-grad`
104+
exists for exactly one purpose — a subtle lift on the single most important KPI card — never a page
105+
background, never a header banner, never more than one card on the whole dashboard.
106+
- **No emoji as icons, bullets, or section markers.** Not in KPI labels, not in section headers, not
107+
as a substitute for a real icon. If a visual marker is needed, use a plain shape (a dot, a small
108+
colored square in a legend) — never 📊📈💰✨ etc.
109+
- **No oversized rounded "blob" shapes, decorative background circles, or glassmorphism for its own
110+
sake.** `backdrop-filter`/translucency is not part of this theme — don't add it. Every visual
111+
element must carry information (a card, a chart, a legend swatch); nothing is decoration.
112+
- **No uniform drop-shadow on every element.** `--ds-shadow` is for cards that sit on `--ds-bg`
113+
don't add extra shadows to buttons, badges, or text, and don't stack multiple shadow layers for
114+
"depth." Flat and quiet is correct here.
115+
- **No arbitrary one-off spacing or font sizes.** Pick from a small fixed scale and stay on it for
116+
the whole document:
117+
- Spacing: `4px 8px 12px 16px 24px 32px` — nothing between these, nothing larger without a real reason.
118+
- Type: 3 sizes total — a KPI number (~28–32px, bold), section/card headings (~14–15px, semibold),
119+
body/labels (~12–13px, regular). Don't introduce a fourth size for a one-off caption.
120+
- **No centered "hero" layout with everything stacked in one narrow column.** This is a working
121+
dashboard, not a landing page — use a real grid (`auto-fit`/`auto-fill` KPI row, multi-column chart
122+
layout) that uses the available width purposefully.
123+
- **Chart color discipline:** stick to the theme's own greyscale chart palette (already built into
124+
`deepsql.charts.*` — you don't choose chart colors). Don't override `opts.color` per chart to
125+
introduce your own arbitrary hues; the built-in palette is the whole point of using the shared
126+
chart runtime instead of hand-rolled SVG.
127+
- **One hero KPI, not a "hero row."** If more than one card gets the gradient/accent treatment,
128+
none of them read as important — that defeats the point. Pick the single number the business
129+
question is actually about and reserve the accent for it alone.
130+
131+
Before emitting, ask: **would this ship, unedited, from a design team that obsesses over every
132+
pixel — or does it look like the first thing a template generator produced?** If any of the
133+
patterns above are present, that's your answer.
134+
83135
## Interaction
84136

85137
- Wire controls to re-run only the affected queries and re-render — never reload the page. A date range picker defaults to what the user asked for (e.g. today) and drives every time-sensitive query.

backend/src/main/java/com/dbaagent/service/DashboardAgentService.java

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@
55
import com.fasterxml.jackson.databind.ObjectMapper;
66
import org.slf4j.Logger;
77
import org.slf4j.LoggerFactory;
8+
import org.springframework.ai.chat.client.ChatClient;
9+
import org.springframework.ai.chat.messages.Message;
10+
import org.springframework.ai.chat.messages.SystemMessage;
11+
import org.springframework.ai.chat.messages.UserMessage;
12+
import org.springframework.ai.chat.model.ChatModel;
813
import org.springframework.http.HttpStatus;
914
import org.springframework.stereotype.Service;
1015
import org.springframework.web.server.ResponseStatusException;
@@ -51,15 +56,18 @@ public interface StepListener {
5156
private final AccessControlService accessControlService;
5257
private final AgentBridgeService agentBridgeService;
5358
private final AgentChatClient agentChatClient;
59+
private final ChatClient intentChatClient;
5460

5561
public DashboardAgentService(ObjectMapper objectMapper,
5662
AccessControlService accessControlService,
5763
AgentBridgeService agentBridgeService,
58-
AgentChatClient agentChatClient) {
64+
AgentChatClient agentChatClient,
65+
ChatModel chatModel) {
5966
this.objectMapper = objectMapper;
6067
this.accessControlService = accessControlService;
6168
this.agentBridgeService = agentBridgeService;
6269
this.agentChatClient = agentChatClient;
70+
this.intentChatClient = ChatClient.builder(chatModel).build();
6371
}
6472

6573
public Map<String, Object> generate(String connectionId, String prompt, Object currentConfig, StepListener listener) {
@@ -75,6 +83,26 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
7583
throw new ResponseStatusException(HttpStatus.BAD_GATEWAY, "The DeepSQL agent is unavailable right now.");
7684
}
7785

86+
// Most messages are a real build/edit ask, so default to the full pipeline —
87+
// only skip it for something that plainly isn't one (a greeting, a question
88+
// about the tool itself). Answering "hi" by grounding on the schema, writing
89+
// SQL, and self-reviewing an HTML document is where the multi-minute replies
90+
// to trivial messages came from.
91+
if (isChatOnly(prompt)) {
92+
emit(l, trace, "planning", "Replying…");
93+
AgentChatClient.AgentReply chatReply = agentChatClient.sendAndAwait(sessionId, buildChatTask(prompt));
94+
if (chatReply.ok() && chatReply.text() != null && !chatReply.text().isBlank()) {
95+
emit(l, trace, "done", "Replied");
96+
Map<String, Object> chat = new LinkedHashMap<>();
97+
chat.put("chat", true);
98+
chat.put("reply", chatReply.text().trim());
99+
chat.put("trace", trace);
100+
return chat;
101+
}
102+
// Ambiguous or the agent didn't just answer — fall through to a real build
103+
// rather than surfacing a failure for what might be a legitimate request.
104+
}
105+
78106
emit(l, trace, "planning", "Agent is grounding, writing SQL, and coding the dashboard…");
79107
AgentChatClient.AgentReply reply = agentChatClient.sendAndAwait(
80108
sessionId, buildTask(connectionId, prompt, currentConfig));
@@ -106,6 +134,51 @@ public Map<String, Object> generate(String connectionId, String prompt, Object c
106134
return cfg;
107135
}
108136

137+
// ── chat-only detection ─────────────────────────────────────────────────
138+
139+
// A one-word classification call, not a keyword match: a fixed word list can't
140+
// tell "make it prettier" or "no, the other one" from a real edit ask. This is a
141+
// direct ChatModel call (no agent session, no tools) so it stays fast — the whole
142+
// point is answering "hi" without paying for a grounding+SQL+self-review turn.
143+
// Biased toward CHAT=false (i.e. toward the full pipeline) in the prompt itself:
144+
// a wrong "this is chat" guess on a real request is far worse than an occasional
145+
// unnecessary grounding pass on a genuine one-word greeting.
146+
private static final String INTENT_SYSTEM_PROMPT = """
147+
Classify one chat message from a BI dashboard builder. Decide whether it is a
148+
request to build, edit, or change a chart/dashboard/metric/data view (CHAT=false),
149+
or plainly just conversation — a greeting, thanks, or a question about the tool
150+
itself with no dashboard content in it (CHAT=true).
151+
152+
If in doubt, answer false — treat anything that could plausibly be about the data
153+
or the dashboard's content/appearance as a real request, even if short or vague
154+
("make it prettier", "no, the other one", "add a filter").
155+
156+
Reply with exactly one word, "true" or "false". No punctuation, no explanation.
157+
""";
158+
private static final int CHAT_ONLY_MAX_CHARS = 200;
159+
160+
private boolean isChatOnly(String prompt) {
161+
if (prompt == null) return false;
162+
String p = prompt.trim();
163+
if (p.isEmpty() || p.length() > CHAT_ONLY_MAX_CHARS) return false;
164+
try {
165+
List<Message> messages = List.of(new SystemMessage(INTENT_SYSTEM_PROMPT), new UserMessage(p));
166+
String verdict = intentChatClient.prompt().messages(messages).call().content();
167+
return verdict != null && verdict.trim().toLowerCase().startsWith("true");
168+
} catch (Exception e) {
169+
log.warn("Chat-intent classification failed, defaulting to full pipeline: {}", e.getMessage());
170+
return false;
171+
}
172+
}
173+
174+
private String buildChatTask(String prompt) {
175+
return "The user sent this message in the dashboard builder's chat: \"" + prompt.trim() + "\"\n\n"
176+
+ "It does not read as a request to build or change a chart/dashboard — it looks like a "
177+
+ "greeting, small talk, or a question about what you can do. Reply briefly and naturally "
178+
+ "in plain text (no HTML, no code block, no tool calls, no grounding, no SQL). If it's a "
179+
+ "greeting, greet back and invite them to describe a dashboard. Keep it to 1-2 sentences.";
180+
}
181+
109182
// ── the task the agent runs ────────────────────────────────────────────
110183

111184
private String buildTask(String connectionId, String prompt, Object currentConfig) {

src/components/DashboardArtifact.jsx

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,25 @@ const BRIDGE = `
3333
if (d.error) p.reject(new Error(d.error));
3434
else p.resolve({ columns: d.columns || [], rows: d.rows || [] });
3535
});
36+
// Report only the content's own height, never the iframe's current rendered
37+
// height — scrollHeight on a body with height:auto reflects content size and
38+
// can't be inflated by whatever height the parent last set, so this can't
39+
// feed back into itself. Debounced and deduped so parent-side layout thrash
40+
// (e.g. a page scroll) can't retrigger it with the same value.
41+
var lastReported=-1, reportTimer=null;
3642
function reportHeight(){
37-
var h = Math.max(document.body ? document.body.scrollHeight : 0,
38-
document.documentElement ? document.documentElement.scrollHeight : 0);
39-
send({ __deepsql:true, type:'height', value: h });
43+
if (reportTimer) return;
44+
reportTimer = setTimeout(function(){
45+
reportTimer = null;
46+
var h = document.documentElement ? document.documentElement.scrollHeight : 0;
47+
if (h === lastReported) return;
48+
lastReported = h;
49+
send({ __deepsql:true, type:'height', value: h });
50+
}, 50);
4051
}
4152
window.addEventListener('load', function(){
4253
reportHeight();
4354
try { new ResizeObserver(reportHeight).observe(document.body); } catch(e){}
44-
setInterval(reportHeight, 1000);
4555
});
4656
window.addEventListener('error', function(e){
4757
send({ __deepsql:true, type:'jserror', message: (e && e.message) || 'script error' });
@@ -84,9 +94,13 @@ const MAX_TOTAL_QUERIES = 400
8494
const QUERY_TIMEOUT_MS = 25000
8595
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
8696

97+
const REDUCED_MOTION = typeof window !== 'undefined'
98+
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
99+
87100
export default function DashboardArtifact({ connectionId, html, onError, queryFn }) {
88101
const iframeRef = useRef(null)
89102
const [height, setHeight] = useState(600)
103+
const [loaded, setLoaded] = useState(false)
90104
const queueRef = useRef([])
91105
const inflightRef = useRef(0)
92106
const totalRef = useRef(0)
@@ -162,11 +176,13 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn
162176
}, [onMessage])
163177

164178
// New artifact (generate/edit) reloads the iframe — reset the throttle so a
165-
// fresh dashboard isn't blocked by the prior one's runaway cap.
179+
// fresh dashboard isn't blocked by the prior one's runaway cap, and fade the
180+
// new one in rather than popping at whatever height it first reports.
166181
useEffect(() => {
167182
queueRef.current = []
168183
inflightRef.current = 0
169184
totalRef.current = 0
185+
setLoaded(false)
170186
}, [html])
171187

172188
return (
@@ -175,7 +191,19 @@ export default function DashboardArtifact({ connectionId, html, onError, queryFn
175191
title="Dashboard"
176192
sandbox="allow-scripts"
177193
srcDoc={buildSrcDoc(html || '', connectionId)}
178-
style={{ width: '100%', height, border: 'none', display: 'block', background: '#f8fafc' }}
194+
onLoad={() => setLoaded(true)}
195+
style={{
196+
width: '100%',
197+
height,
198+
border: 'none',
199+
display: 'block',
200+
background: '#f8fafc',
201+
opacity: loaded ? 1 : 0,
202+
// Height snaps immediately, never transitions — animating it risked measuring
203+
// the iframe's own in-transition rendered height as if it were new content,
204+
// a feedback loop that made the page grow on every scroll/resize tick.
205+
transition: REDUCED_MOTION ? 'opacity 150ms linear' : 'opacity 320ms cubic-bezier(0.2, 0.8, 0.2, 1)',
206+
}}
179207
/>
180208
)
181209
}

0 commit comments

Comments
 (0)