Skip to content

Commit 325b601

Browse files
refactor: some improved ux. (#4)
1 parent 0dd4269 commit 325b601

6 files changed

Lines changed: 644 additions & 90 deletions

File tree

docs/next-steps.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Next Steps
2+
3+
Focused follow-up work for `@knighted/develop`.
4+
5+
1. **Grid-first header/layout cleanup**
6+
- Refactor panel header layout to use CSS Grid as the primary layout mechanism.
7+
- Reduce wrapper rows where possible and place controls explicitly in grid areas.
8+
- Preserve existing semantics and accessibility behavior while simplifying structure.
9+
- Validate desktop/mobile breakpoints and keep visual behavior parity.
10+
11+
2. **Style isolation behavior docs**
12+
- Document ShadowRoot on/off behavior and how style isolation changes in light DOM mode.
13+
- Clarify that light DOM preview can inherit shell styles and include recommendations for scoping.
14+
15+
3. **Preview UX polish**
16+
- Keep tooltip affordances for mode-specific behavior.
17+
- Continue tightening panel control alignment and spacing without introducing extra markup.
18+
19+
4. **Theming (light + dark)**
20+
- Keep the existing dark mode as the baseline and add a first-class light theme.
21+
- Move key colors to semantic CSS variables and define both theme palettes.
22+
- Ensure component panels, controls, editor chrome, preview shell, and tooltips all have complete light-mode coverage.
23+
- Verify contrast/accessibility across both themes and preserve visual hierarchy parity.

package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
{
22
"name": "@knighted/develop",
33
"version": "0.1.0",
4-
"description": "Develop UI components directly in the browser.",
4+
"description": "Develop UI components directly in the browser using JSX and CSS.",
55
"keywords": [
66
"ui",
77
"components",
88
"realtime",
99
"browser",
10-
"development"
10+
"development",
11+
"jsx",
12+
"css",
13+
"importmap",
14+
"cdn"
1115
],
1216
"license": "MIT",
1317
"author": "KCM <knightedcodemonkey@gmail.com>",

src/app.js

Lines changed: 192 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,71 +1,27 @@
11
import { cdnImports, importFromCdnWithFallback } from './cdn.js'
22
import { createCodeMirrorEditor } from './editor-codemirror.js'
3+
import { defaultCss, defaultJsx } from './defaults.js'
34

45
const statusNode = document.getElementById('status')
56
const renderMode = document.getElementById('render-mode')
67
const autoRenderToggle = document.getElementById('auto-render')
78
const renderButton = document.getElementById('render-button')
9+
const copyComponentButton = document.getElementById('copy-component')
10+
const clearComponentButton = document.getElementById('clear-component')
811
const styleMode = document.getElementById('style-mode')
12+
const copyStylesButton = document.getElementById('copy-styles')
13+
const clearStylesButton = document.getElementById('clear-styles')
914
const shadowToggle = document.getElementById('shadow-toggle')
1015
const jsxEditor = document.getElementById('jsx-editor')
1116
const cssEditor = document.getElementById('css-editor')
12-
const previewHost = document.getElementById('preview-host')
1317
const styleWarning = document.getElementById('style-warning')
1418
const cdnLoading = document.getElementById('cdn-loading')
15-
16-
const defaultJsx = [
17-
'const Button = ({ onClick }) => {',
18-
' return <button onClick={onClick}>click me</button>',
19-
'}',
20-
'',
21-
'const App = () => {',
22-
' const onClick = () => {',
23-
" alert('clicked!')",
24-
' }',
25-
'',
26-
' return <Button onClick={onClick} />',
27-
'}',
28-
'',
29-
].join('\n')
30-
31-
const defaultCss = `button {
32-
appearance: none;
33-
border: 1px solid rgba(122, 107, 255, 0.55);
34-
background: linear-gradient(135deg, #7a6bff, #5f4dff);
35-
color: #fff;
36-
padding: 10px 16px;
37-
border-radius: 10px;
38-
font-weight: 700;
39-
letter-spacing: 0.01em;
40-
cursor: pointer;
41-
transition:
42-
transform 120ms ease,
43-
box-shadow 120ms ease,
44-
filter 120ms ease;
45-
box-shadow:
46-
0 8px 20px rgba(95, 77, 255, 0.28),
47-
inset 0 1px 0 rgba(255, 255, 255, 0.18);
48-
}
49-
50-
button:hover {
51-
transform: translateY(-1px);
52-
filter: brightness(1.06);
53-
}
54-
55-
button:active {
56-
transform: translateY(0);
57-
filter: brightness(0.98);
58-
}
59-
60-
button:focus-visible {
61-
outline: 2px solid #9d91ff;
62-
outline-offset: 2px;
63-
}
64-
`
19+
const previewBgColorInput = document.getElementById('preview-bg-color')
6520

6621
jsxEditor.value = defaultJsx
6722
cssEditor.value = defaultCss
6823

24+
let previewHost = document.getElementById('preview-host')
6925
let jsxCodeEditor = null
7026
let cssCodeEditor = null
7127
let getJsxSource = () => jsxEditor.value
@@ -82,6 +38,8 @@ let compiledStylesCache = {
8238
value: null,
8339
}
8440
let hasCompletedInitialRender = false
41+
let previewBackgroundColor = null
42+
const clipboardSupported = Boolean(navigator.clipboard?.writeText)
8543

8644
const styleLabels = {
8745
css: 'Native CSS',
@@ -196,7 +154,140 @@ const debounceRender = () => {
196154
scheduled = setTimeout(renderPreview, 200)
197155
}
198156

199-
const getShadowRoot = () => {
157+
const setJsxSource = value => {
158+
if (jsxCodeEditor) {
159+
jsxCodeEditor.setValue(value)
160+
}
161+
jsxEditor.value = value
162+
}
163+
164+
const setCssSource = value => {
165+
if (cssCodeEditor) {
166+
cssCodeEditor.setValue(value)
167+
}
168+
cssEditor.value = value
169+
}
170+
171+
const clearComponentSource = () => {
172+
setJsxSource('')
173+
if (!jsxCodeEditor) {
174+
maybeRender()
175+
}
176+
}
177+
178+
const clearStylesSource = () => {
179+
setCssSource('')
180+
if (!cssCodeEditor) {
181+
maybeRender()
182+
}
183+
}
184+
185+
const copyTextToClipboard = async text => {
186+
if (!clipboardSupported) {
187+
throw new Error('Clipboard API is not available in this browser context.')
188+
}
189+
190+
await navigator.clipboard.writeText(text)
191+
}
192+
193+
const copyComponentSource = async () => {
194+
try {
195+
await copyTextToClipboard(getJsxSource())
196+
setStatus('Component copied')
197+
} catch {
198+
setStatus('Copy failed')
199+
}
200+
}
201+
202+
const copyStylesSource = async () => {
203+
try {
204+
await copyTextToClipboard(getCssSource())
205+
setStatus('Styles copied')
206+
} catch {
207+
setStatus('Copy failed')
208+
}
209+
}
210+
211+
const toHexChannel = value => value.toString(16).padStart(2, '0')
212+
213+
const normalizeColorToHex = colorValue => {
214+
if (typeof colorValue !== 'string' || colorValue.length === 0) {
215+
return '#12141c'
216+
}
217+
218+
if (/^#[\da-f]{6}$/i.test(colorValue)) {
219+
return colorValue.toLowerCase()
220+
}
221+
222+
if (/^#[\da-f]{3}$/i.test(colorValue)) {
223+
return colorValue
224+
.slice(1)
225+
.split('')
226+
.map(channel => channel + channel)
227+
.join('')
228+
.replace(/^/, '#')
229+
.toLowerCase()
230+
}
231+
232+
const channels = colorValue.match(/\d+/g)
233+
if (!channels || channels.length < 3) {
234+
return '#12141c'
235+
}
236+
237+
const [red, green, blue] = channels.slice(0, 3).map(value => Number.parseInt(value, 10))
238+
if ([red, green, blue].some(value => Number.isNaN(value))) {
239+
return '#12141c'
240+
}
241+
242+
return `#${toHexChannel(red)}${toHexChannel(green)}${toHexChannel(blue)}`
243+
}
244+
245+
const applyPreviewBackgroundColor = color => {
246+
if (!previewHost) {
247+
return
248+
}
249+
250+
previewHost.style.backgroundColor = color
251+
}
252+
253+
const initializePreviewBackgroundPicker = () => {
254+
if (!previewBgColorInput || !previewHost) {
255+
return
256+
}
257+
258+
const initialColor = normalizeColorToHex(getComputedStyle(previewHost).backgroundColor)
259+
previewBackgroundColor = initialColor
260+
previewBgColorInput.value = initialColor
261+
applyPreviewBackgroundColor(initialColor)
262+
263+
previewBgColorInput.addEventListener('input', () => {
264+
previewBackgroundColor = previewBgColorInput.value
265+
applyPreviewBackgroundColor(previewBackgroundColor)
266+
})
267+
}
268+
269+
const recreatePreviewHost = () => {
270+
const nextHost = document.createElement('div')
271+
nextHost.id = 'preview-host'
272+
nextHost.className = previewHost.className
273+
previewHost.replaceWith(nextHost)
274+
previewHost = nextHost
275+
276+
if (previewBackgroundColor) {
277+
applyPreviewBackgroundColor(previewBackgroundColor)
278+
}
279+
}
280+
281+
const getRenderTarget = () => {
282+
if (!shadowToggle.checked && previewHost.shadowRoot) {
283+
/* ShadowRoot cannot be detached, so recreate the host for light DOM mode. */
284+
if (reactRoot) {
285+
reactRoot.unmount()
286+
reactRoot = null
287+
}
288+
recreatePreviewHost()
289+
}
290+
200291
if (shadowToggle.checked) {
201292
if (!previewHost.shadowRoot) {
202293
previewHost.attachShadow({ mode: 'open' })
@@ -234,7 +325,10 @@ const applyStyles = (target, cssText) => {
234325
if (!target) return
235326

236327
const styleTag = document.createElement('style')
237-
styleTag.textContent = cssText
328+
const isShadowTarget = target instanceof ShadowRoot
329+
styleTag.textContent = isShadowTarget
330+
? cssText
331+
: `@scope (#preview-host) {\n${cssText}\n}`
238332
target.append(styleTag)
239333
}
240334

@@ -554,15 +648,33 @@ const evaluateUserModule = async (helpers = {}) => {
554648
throw error
555649
}
556650

557-
const transpiledUserCode = transpileJsxSource(userCode, {
558-
sourceType: 'script',
559-
}).code
651+
const transpileMode = helpers.React && helpers.reactJsx ? 'react' : 'dom'
652+
const transpileOptionsByMode = {
653+
dom: {
654+
sourceType: 'script',
655+
createElement: 'jsx.createElement',
656+
fragment: 'jsx.Fragment',
657+
},
658+
react: {
659+
sourceType: 'script',
660+
createElement: 'React.createElement',
661+
fragment: 'React.Fragment',
662+
},
663+
}
664+
const transpiledUserCode = transpileJsxSource(
665+
userCode,
666+
transpileOptionsByMode[transpileMode],
667+
).code
560668
const moduleFactory = createUserModuleFactory(transpiledUserCode)
561669

562670
if (helpers.React && helpers.reactJsx) {
563671
return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React)
564672
}
565673

674+
if (transpileMode === 'dom') {
675+
return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx, helpers.React)
676+
}
677+
566678
const { React, reactJsx } = await ensureReactRuntime()
567679
return moduleFactory(helpers.jsx ?? jsx, helpers.reactJsx ?? reactJsx, React)
568680
}
@@ -605,7 +717,7 @@ const ensureReactRuntime = async () => {
605717

606718
const renderDom = async () => {
607719
const { jsx } = await ensureCoreRuntime()
608-
const target = getShadowRoot()
720+
const target = getRenderTarget()
609721
clearTarget(target)
610722
const compiledStyles = await compileStyles()
611723
applyStyles(target, compiledStyles.css)
@@ -627,13 +739,13 @@ const renderDom = async () => {
627739
}
628740

629741
const renderReact = async () => {
630-
const target = getShadowRoot()
742+
const target = getRenderTarget()
631743
clearTarget(target)
632744
const compiledStyles = await compileStyles()
633745
applyStyles(target, compiledStyles.css)
634746

635747
const { reactJsx, createRoot, React } = await ensureReactRuntime()
636-
const renderFn = await evaluateUserModule({ jsx: reactJsx, reactJsx })
748+
const renderFn = await evaluateUserModule({ jsx: reactJsx, reactJsx, React })
637749
if (!renderFn) {
638750
throw new Error('Expected a render() function or a component named App/View.')
639751
}
@@ -666,7 +778,7 @@ const renderPreview = async () => {
666778
setStatus('Rendered')
667779
} catch (error) {
668780
setStatus('Error')
669-
const target = getShadowRoot()
781+
const target = getRenderTarget()
670782
clearTarget(target)
671783
const message = document.createElement('pre')
672784
message.textContent = error instanceof Error ? error.message : String(error)
@@ -686,6 +798,10 @@ const maybeRender = () => {
686798
}
687799
}
688800

801+
const updateRenderButtonVisibility = () => {
802+
renderButton.hidden = autoRenderToggle.checked
803+
}
804+
689805
renderMode.addEventListener('change', maybeRender)
690806
styleMode.addEventListener('change', () => {
691807
if (cssCodeEditor) {
@@ -695,15 +811,31 @@ styleMode.addEventListener('change', () => {
695811
})
696812
shadowToggle.addEventListener('change', maybeRender)
697813
autoRenderToggle.addEventListener('change', () => {
814+
updateRenderButtonVisibility()
698815
if (autoRenderToggle.checked) {
699816
renderPreview()
700817
}
701818
})
702819
renderButton.addEventListener('click', renderPreview)
820+
if (clipboardSupported) {
821+
copyComponentButton.addEventListener('click', () => {
822+
void copyComponentSource()
823+
})
824+
copyStylesButton.addEventListener('click', () => {
825+
void copyStylesSource()
826+
})
827+
} else {
828+
copyComponentButton.hidden = true
829+
copyStylesButton.hidden = true
830+
}
831+
clearComponentButton.addEventListener('click', clearComponentSource)
832+
clearStylesButton.addEventListener('click', clearStylesSource)
703833
jsxEditor.addEventListener('input', maybeRender)
704834
cssEditor.addEventListener('input', maybeRender)
705835

836+
updateRenderButtonVisibility()
706837
setStyleCompiling(false)
707838
setCdnLoading(true)
839+
initializePreviewBackgroundPicker()
708840
void initializeCodeEditors()
709841
renderPreview()

0 commit comments

Comments
 (0)