Skip to content

Commit bb08ce3

Browse files
committed
feat(ui): add postgresql plugin surface
1 parent 9323e47 commit bb08ce3

14 files changed

Lines changed: 1062 additions & 59 deletions

File tree

scripts/audit-simple-easy-ui.mjs

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import fs from 'node:fs/promises'
2+
import path from 'node:path'
3+
import playwright from '../src/frontend/node_modules/playwright/index.js'
4+
5+
const { chromium } = playwright
6+
7+
const root = path.resolve(new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'))
8+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
9+
const outDir = path.join(root, 'output', 'playwright', `simple-easy-audit-${stamp}`)
10+
await fs.mkdir(outDir, { recursive: true })
11+
12+
const routes = (process.env.AUDIT_ROUTES ?? '/sites,/settings')
13+
.split(',')
14+
.map(x => x.trim())
15+
.filter(Boolean)
16+
const viewportsAll = [
17+
{ name: 'desktop', width: 1440, height: 960 },
18+
{ name: 'tablet', width: 768, height: 900 },
19+
{ name: 'mobile', width: 390, height: 844 },
20+
]
21+
const viewportFilter = new Set((process.env.AUDIT_VIEWPORTS ?? '')
22+
.split(',')
23+
.map(x => x.trim())
24+
.filter(Boolean))
25+
const viewports = viewportFilter.size > 0
26+
? viewportsAll.filter(v => viewportFilter.has(v.name))
27+
: viewportsAll
28+
const themes = (process.env.AUDIT_THEMES ?? 'dark,light')
29+
.split(',')
30+
.map(x => x.trim())
31+
.filter(Boolean)
32+
33+
const report = {
34+
startedAt: new Date().toISOString(),
35+
outDir,
36+
screens: [],
37+
console: [],
38+
pageErrors: [],
39+
badResponses: [],
40+
}
41+
42+
function safeName(input) {
43+
return input.replace(/^\/+/, '').replace(/[^\w.-]+/g, '_') || 'root'
44+
}
45+
46+
async function currentMainPage(browser) {
47+
const contexts = browser.contexts()
48+
for (const context of contexts) {
49+
const pages = context.pages()
50+
const appPage = pages.find(p => p.url().includes('127.0.0.1:5190') || p.url().includes('localhost:5190'))
51+
if (appPage) return appPage
52+
if (pages[0]) return pages[0]
53+
}
54+
throw new Error('No Electron page available over CDP')
55+
}
56+
57+
async function waitSettled(page) {
58+
await page.waitForLoadState('domcontentloaded').catch(() => {})
59+
await page.locator('.sites-simple, .settings-page').first().waitFor({ state: 'visible', timeout: 6000 }).catch(() => {})
60+
await page.locator('.splash-overlay, .splash-screen, .app-loading').first().waitFor({ state: 'hidden', timeout: 6000 }).catch(() => {})
61+
await page.waitForTimeout(500)
62+
}
63+
64+
async function setSimpleMode(page) {
65+
const mode = process.env.AUDIT_UI_MODE ?? 'simple'
66+
await page.evaluate((mode) => localStorage.setItem('wdc-ui-mode', mode), mode)
67+
}
68+
69+
async function setTheme(page, theme) {
70+
await page.evaluate((theme) => {
71+
localStorage.setItem('nks-wdc-theme', theme)
72+
document.documentElement.classList.toggle('dark', theme === 'dark')
73+
}, theme)
74+
}
75+
76+
async function screenshot(page, name) {
77+
const file = path.join(outDir, `${name}.png`)
78+
await page.screenshot({ path: file, fullPage: true, timeout: 15000 })
79+
return path.relative(root, file).replaceAll('\\', '/')
80+
}
81+
82+
async function metrics(page) {
83+
return await page.evaluate(() => {
84+
const parseColor = (raw) => {
85+
const match = String(raw).match(/rgba?\(([^)]+)\)/)
86+
if (!match) return null
87+
const parts = match[1].split(',').map(x => Number.parseFloat(x.trim()))
88+
return { r: parts[0], g: parts[1], b: parts[2], a: parts[3] ?? 1 }
89+
}
90+
const luminance = (c) => {
91+
const vals = [c.r, c.g, c.b].map(v => {
92+
const s = v / 255
93+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
94+
})
95+
return 0.2126 * vals[0] + 0.7152 * vals[1] + 0.0722 * vals[2]
96+
}
97+
const ratio = (fg, bg) => {
98+
const a = luminance(fg)
99+
const b = luminance(bg)
100+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)
101+
}
102+
const backgroundFor = (el) => {
103+
let cur = el
104+
while (cur && cur !== document.documentElement) {
105+
const bg = parseColor(getComputedStyle(cur).backgroundColor)
106+
if (bg && bg.a > 0.05) return bg
107+
cur = cur.parentElement
108+
}
109+
return parseColor(getComputedStyle(document.body).backgroundColor) || { r: 255, g: 255, b: 255, a: 1 }
110+
}
111+
const visible = (el) => {
112+
const rect = el.getBoundingClientRect()
113+
const cs = getComputedStyle(el)
114+
return rect.width > 0 && rect.height > 0 && cs.visibility !== 'hidden' && cs.display !== 'none'
115+
}
116+
const textNodes = [...document.querySelectorAll('h1,h2,h3,h4,p,label,button,a,td,th,li,span,.el-tag,.hint,.form-hint,.tab-desc')]
117+
.filter(visible)
118+
.filter(el => (el.textContent || '').trim().length > 0)
119+
.slice(0, 500)
120+
const lowContrast = []
121+
const smallText = []
122+
const clipped = []
123+
for (const el of textNodes) {
124+
const rect = el.getBoundingClientRect()
125+
const cs = getComputedStyle(el)
126+
const fg = parseColor(cs.color)
127+
const bg = backgroundFor(el)
128+
const text = (el.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 90)
129+
const fontSize = Number.parseFloat(cs.fontSize)
130+
if (fontSize < 11) smallText.push({ text, fontSize: Number(fontSize.toFixed(1)) })
131+
if (rect.right > window.innerWidth + 2 || rect.left < -2) clipped.push({ text, left: Math.round(rect.left), right: Math.round(rect.right) })
132+
if (fg && bg) {
133+
const r = ratio(fg, bg)
134+
const required = fontSize >= 18 || (fontSize >= 14 && Number.parseInt(cs.fontWeight, 10) >= 600) ? 3 : 4.5
135+
if (r < required) lowContrast.push({ text, ratio: Number(r.toFixed(2)), required })
136+
}
137+
}
138+
const targets = [...document.querySelectorAll('button,a,[role="button"],input,select,textarea,.el-switch,.el-checkbox,.el-radio')]
139+
.filter(visible)
140+
.slice(0, 350)
141+
const smallTargets = []
142+
for (const el of targets) {
143+
const rect = el.getBoundingClientRect()
144+
const text = (el.textContent || el.getAttribute('aria-label') || el.getAttribute('title') || el.getAttribute('placeholder') || el.tagName).trim().replace(/\s+/g, ' ').slice(0, 90)
145+
if (rect.width < 32 || rect.height < 32) smallTargets.push({ text, width: Math.round(rect.width), height: Math.round(rect.height) })
146+
}
147+
return {
148+
url: location.href,
149+
bodyTextLength: document.body.innerText.length,
150+
horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 2,
151+
scrollWidth: document.documentElement.scrollWidth,
152+
innerWidth: window.innerWidth,
153+
lowContrast: lowContrast.slice(0, 40),
154+
smallText: smallText.slice(0, 40),
155+
smallTargets: smallTargets.slice(0, 40),
156+
clipped: clipped.slice(0, 40),
157+
}
158+
})
159+
}
160+
161+
const browser = await chromium.connectOverCDP('http://127.0.0.1:9222')
162+
const page = await currentMainPage(browser)
163+
page.setDefaultTimeout(4000)
164+
page.on('console', (msg) => {
165+
if (['error', 'warning'].includes(msg.type())) report.console.push({ type: msg.type(), text: msg.text(), url: page.url() })
166+
})
167+
page.on('pageerror', (err) => report.pageErrors.push({ message: err.message, stack: err.stack, url: page.url() }))
168+
page.on('response', (res) => {
169+
if (res.status() >= 400) report.badResponses.push({ status: res.status(), url: res.url(), page: page.url() })
170+
})
171+
172+
try {
173+
await setSimpleMode(page)
174+
for (const theme of themes) {
175+
await setTheme(page, theme)
176+
for (const vp of viewports) {
177+
await page.setViewportSize({ width: vp.width, height: vp.height })
178+
for (const route of routes) {
179+
await page.goto(`http://127.0.0.1:5190/?audit=${Date.now()}#${route}`, { waitUntil: 'domcontentloaded', timeout: 6000 })
180+
await waitSettled(page)
181+
const shot = await screenshot(page, `${theme}-${vp.name}-${safeName(route)}`)
182+
report.screens.push({ theme, viewport: vp, route, screenshot: shot, metrics: await metrics(page) })
183+
}
184+
}
185+
}
186+
const siteLinks = await page.locator('a,button,.site-card,.site-mobile-main').count().catch(() => 0)
187+
report.siteClickTargets = siteLinks
188+
report.finishedAt = new Date().toISOString()
189+
} finally {
190+
await fs.writeFile(path.join(outDir, 'report.json'), JSON.stringify(report, null, 2))
191+
const lines = [
192+
'# Simple/Easy UI Audit',
193+
'',
194+
`Started: ${report.startedAt}`,
195+
`Finished: ${report.finishedAt ?? '(interrupted)'}`,
196+
`Screens: ${report.screens.length}`,
197+
`Console warnings/errors: ${report.console.length}`,
198+
`Page errors: ${report.pageErrors.length}`,
199+
`HTTP >=400 responses: ${report.badResponses.length}`,
200+
'',
201+
'## Screens',
202+
...report.screens.map(s => `- ${s.theme} ${s.viewport.name} ${s.route}: overflow=${s.metrics.horizontalOverflow} lowContrast=${s.metrics.lowContrast.length} clipped=${s.metrics.clipped.length} smallTargets=${s.metrics.smallTargets.length} screenshot=\`${s.screenshot}\``),
203+
]
204+
await fs.writeFile(path.join(outDir, 'summary.md'), lines.join('\n'))
205+
console.log(JSON.stringify({ outDir, screens: report.screens.length, console: report.console.length, pageErrors: report.pageErrors.length, badResponses: report.badResponses.length }, null, 2))
206+
await page.setViewportSize({ width: 1440, height: 960 }).catch(() => {})
207+
await setTheme(page, 'dark').catch(() => {})
208+
await page.goto('http://127.0.0.1:5190/#/sites', { waitUntil: 'domcontentloaded', timeout: 5000 }).catch(() => {})
209+
await browser.close().catch(() => {})
210+
}

src/frontend/src/assets/tokens.css

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,33 @@ html:not(.dark) {
198198
}
199199

200200
/* ─── Base Reset ─────────────────────────────────────────────────────────── */
201+
html:not(.dark) {
202+
--el-bg-color: var(--wdc-surface);
203+
--el-bg-color-page: var(--wdc-bg);
204+
--el-bg-color-overlay: var(--wdc-elevated);
205+
--el-border-color: var(--wdc-border);
206+
--el-border-color-light: var(--wdc-border);
207+
--el-border-color-lighter: rgba(12, 16, 32, 0.12);
208+
--el-border-color-extra-light: rgba(12, 16, 32, 0.08);
209+
--el-border-color-dark: var(--wdc-border-strong);
210+
--el-border-color-darker: rgba(12, 16, 32, 0.52);
211+
--el-text-color-primary: var(--wdc-text);
212+
--el-text-color-regular: var(--wdc-text-2);
213+
--el-text-color-secondary: var(--wdc-text-3);
214+
--el-text-color-placeholder: #5c6074;
215+
--el-text-color-disabled: #686d82;
216+
--el-fill-color: var(--wdc-surface-2);
217+
--el-fill-color-light: var(--wdc-hover);
218+
--el-fill-color-lighter: #eef1f7;
219+
--el-fill-color-blank: var(--wdc-surface);
220+
--el-fill-color-dark: var(--wdc-elevated);
221+
--el-color-primary: var(--wdc-accent);
222+
--el-color-success: var(--wdc-status-running);
223+
--el-color-warning: var(--wdc-status-starting);
224+
--el-color-danger: var(--wdc-status-error);
225+
--el-color-info: var(--wdc-text-2);
226+
}
227+
201228
*, *::before, *::after {
202229
box-sizing: border-box;
203230
margin: 0;
@@ -526,29 +553,46 @@ html:not(.dark) .el-button {
526553
padding: 0 6px;
527554
}
528555

529-
.el-tag--info.is-plain,
530-
.el-tag--info.is-dark {
556+
.el-tag--info.is-plain {
531557
background: rgba(139, 143, 168, 0.10) !important;
532558
border-color: rgba(139, 143, 168, 0.20) !important;
533559
color: var(--wdc-text-2) !important;
534560
}
535561

562+
.el-tag--info.el-tag--dark {
563+
background: #64748b !important;
564+
border-color: #64748b !important;
565+
color: #ffffff !important;
566+
}
567+
536568
.el-tag--success.is-plain,
537-
.el-tag--success.is-dark {
569+
.el-tag--success.el-tag--dark {
538570
background: rgba(34, 197, 94, 0.10) !important;
539571
border-color: rgba(34, 197, 94, 0.25) !important;
540572
color: var(--wdc-status-running) !important;
541573
}
542574

575+
.el-tag--success.el-tag--dark {
576+
background: var(--wdc-status-running) !important;
577+
border-color: var(--wdc-status-running) !important;
578+
color: #04110a !important;
579+
}
580+
543581
.el-tag--warning.is-plain,
544-
.el-tag--warning.is-dark {
582+
.el-tag--warning.el-tag--dark {
545583
background: rgba(245, 158, 11, 0.10) !important;
546584
border-color: rgba(245, 158, 11, 0.25) !important;
547585
color: var(--wdc-status-starting) !important;
548586
}
549587

588+
.el-tag--warning.el-tag--dark {
589+
background: var(--wdc-status-starting) !important;
590+
border-color: var(--wdc-status-starting) !important;
591+
color: #141006 !important;
592+
}
593+
550594
.el-tag--danger.is-plain,
551-
.el-tag--danger.is-dark {
595+
.el-tag--danger.el-tag--dark {
552596
background: rgba(239, 68, 68, 0.10) !important;
553597
border-color: rgba(239, 68, 68, 0.25) !important;
554598
color: var(--wdc-status-error) !important;
@@ -793,16 +837,67 @@ html:not(.dark) .el-button {
793837
}
794838

795839
/* Switch */
840+
.el-switch {
841+
--el-switch-on-color: var(--wdc-accent);
842+
--el-switch-off-color: var(--wdc-elevated);
843+
min-height: 34px;
844+
vertical-align: middle;
845+
}
846+
796847
.el-switch__core {
848+
width: 48px !important;
849+
min-width: 48px !important;
850+
height: 28px !important;
797851
background: var(--wdc-elevated) !important;
798852
border-color: var(--wdc-border-strong) !important;
799-
min-width: 36px !important;
800-
height: 26px !important;
853+
box-shadow:
854+
inset 0 0 0 1px var(--wdc-border),
855+
inset 0 2px 5px rgba(0, 0, 0, 0.22) !important;
856+
transition: background 0.14s ease, border-color 0.14s ease, box-shadow 0.14s ease !important;
857+
}
858+
859+
.el-switch__core .el-switch__action {
860+
width: 22px !important;
861+
height: 22px !important;
862+
left: 2px !important;
863+
color: transparent !important;
864+
background: var(--wdc-text) !important;
865+
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35) !important;
801866
}
802867

803868
.el-switch.is-checked .el-switch__core {
804869
background: var(--wdc-accent) !important;
805870
border-color: var(--wdc-accent) !important;
871+
box-shadow:
872+
inset 0 0 0 1px color-mix(in srgb, var(--wdc-accent) 75%, var(--wdc-text)),
873+
0 0 0 2px var(--wdc-accent-dim) !important;
874+
}
875+
876+
.el-switch.is-checked .el-switch__core .el-switch__action {
877+
left: calc(100% - 24px) !important;
878+
background: #ffffff !important;
879+
}
880+
881+
.el-switch.is-disabled {
882+
opacity: 0.58;
883+
}
884+
885+
.el-switch__label {
886+
color: var(--wdc-text-2) !important;
887+
font-weight: 600;
888+
}
889+
890+
.el-switch__label.is-active {
891+
color: var(--wdc-text) !important;
892+
}
893+
894+
html:not(.dark) .el-switch__core .el-switch__action {
895+
background: #ffffff !important;
896+
box-shadow: 0 1px 4px rgba(12, 16, 32, 0.26) !important;
897+
}
898+
899+
html:not(.dark) .el-switch.is-checked .el-switch__core .el-switch__action {
900+
background: #ffffff !important;
806901
}
807902

808903
/* Radio button group */

0 commit comments

Comments
 (0)