Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions UI.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ The launcher markup is in [client/BeastDevtools.btsx](client/BeastDevtools.btsx)
**Tools**

- **Component Finder** identifies the component that owns an element and its source file. Click to open the source in your editor. Shortcut: Alt+Shift+C.
- **Element Picker** shows a compact tag/ID and dimensions preview. Clicking opens the Elements panel for live style, attribute and DOM-property edits with undo. Shortcut: Alt+Shift+E.
- **Element Picker** shows a compact tag/ID and dimensions preview. Clicking selects an element for source-backed style, attribute and DOM-property edits with undo. It switches to the Elements tab without opening a closed panel, so layout handles can be used on the unobstructed page. Shortcut: Alt+Shift+E.

[client/ElementsPanel.btsx](client/ElementsPanel.btsx) contains the live editor; [client/element-inspector.ts](client/element-inspector.ts) captures properties and manages edits and undo. [client/element-groups.ts](client/element-groups.ts) sorts styles and DOM properties into the panel's category groups. [client/element-layout.ts](client/element-layout.ts) draws the open element's outline and side handles for resizing it on the page.
[client/ElementsPanel.btsx](client/ElementsPanel.btsx) contains the source-backed editor; [client/element-inspector.ts](client/element-inspector.ts) captures properties and saves edits through the Elements API, with file-backed undo. Styles and layout drags update inline styles in the owning `.btsx` element. Untagged elements and runtime-only properties remain read-only; computed style expressions that cannot be safely patched report an error instead of claiming to save. [client/element-groups.ts](client/element-groups.ts) sorts styles and DOM properties into the panel's category groups. [client/element-layout.ts](client/element-layout.ts) draws the open element's outline and side handles for resizing it on the page.

Both tools appear in the launcher and panel toolbar. Only one is active at a time; Escape exits it.

Expand Down
62 changes: 50 additions & 12 deletions client/BeastDevtools.btsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState, useSyncExternalStore } from 'octane'
import type { AnalyzerSettings, FileReport, ProjectReport } from '../shared/types.ts'
import type { AnalyzerSettings, ComponentLocation, FileReport, FileSummary, ProjectReport } from '../shared/types.ts'
import { BeastLogo, ComponentFinderIcon, ElementPickerIcon } from './Icons.btsx'
import ComponentsPanel from './ComponentsPanel.btsx'
import ElementsPanel from './ElementsPanel.btsx'
import { startLayoutEditor } from './element-layout.ts'
import { ElementInspection } from './element-inspector.ts'
import InspectorPanel from './InspectorPanel.btsx'
import RefactorPanel from './RefactorPanel.btsx'
Expand Down Expand Up @@ -30,6 +31,17 @@ module
return event.altKey && event.shiftKey && !event.metaKey && !event.ctrlKey && event.code === 'KeyE'
}

/**
* Shared empty props. `?? []` inline would mint a fresh array identity on
* every render and defeat the memoized tree/file derivations downstream.
*/
const EMPTY_COMPONENTS: readonly ComponentLocation[] = []
const EMPTY_STRINGS: readonly string[] = []
const EMPTY_FILES: readonly FileSummary[] = []

/** Settings inputs emit per keystroke; server fetches wait for a pause. */
const SETTINGS_DEBOUNCE_MS = 300

module
interface DevtoolsInterfaceProps {
open: boolean
Expand Down Expand Up @@ -62,6 +74,14 @@ setup
const [showControlFlow, setShowControlFlow] = useState(initial.showControlFlow);
const [componentsView, setComponentsView] = useState(initial.componentsView);
const [settings, setSettings] = useState<AnalyzerSettings>(initial.settings);
// Fetches run against the settled value, so typing in a settings field
// fires one refresh instead of one per keystroke.
const [debouncedSettings, setDebouncedSettings] = useState<AnalyzerSettings>(initial.settings);
useEffect(() => {
if (settings === debouncedSettings) return;
const timer = window.setTimeout(() => setDebouncedSettings(settings), SETTINGS_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [settings]);
const [focus, setFocus] = useState<ScrollTarget | null>(null);
const [revision, setRevision] = useState(0);
const [projectId, setProjectId] = useState('');
Expand Down Expand Up @@ -125,7 +145,7 @@ setup
if (!open && event.target === event.currentTarget && event.propertyName === 'visibility') setDocked(false);
};

// Both tools end on selection; Element Picker opens the live editor.
// Picking selects the editor tab without changing whether the dock is open.
useEffect(() => {
if (activeElementTool === null) return;
if (activeElementTool === 'element-picker') return startElementPicker(
Expand All @@ -135,7 +155,6 @@ setup
setElementInspection(inspection);
setActiveElementTool(null);
setTab('elements');
setOpen(true);
},
() => setActiveElementTool(null),
);
Expand All @@ -148,11 +167,15 @@ setup
);
}, [activeElementTool]);

// Layout handles remain usable with the dock closed, and pause while picking.
useEffect(() => elementInspection && activeElementTool === null
? startLayoutEditor(elementInspection, () => {}) : undefined, [elementInspection, activeElementTool]);

// The project list refreshes whenever a .btsx file changes on disk.
useEffect(() => {
if (!open) return;
let cancelled = false;
Promise.all([fetchProject(settings, projectId), fetchProject(settings)]).then(
Promise.all([fetchProject(debouncedSettings, projectId), fetchProject(debouncedSettings)]).then(
([next, running]) => {
if (cancelled || generation !== projectGeneration.current) return;
setRuntimeProject(running);
Expand All @@ -167,12 +190,12 @@ setup
return () => {
cancelled = true;
};
}, [open, revision, settings, projectId]);
}, [open, revision, debouncedSettings, projectId]);

useEffect(() => {
if (!open || file === null) return;
let cancelled = false;
fetchFile(file, settings, projectId).then(
fetchFile(file, debouncedSettings, projectId).then(
(next) => {
if (cancelled || generation !== projectGeneration.current) return;
setReport(next);
Expand All @@ -185,7 +208,7 @@ setup
return () => {
cancelled = true;
};
}, [open, file, revision, settings, projectId]);
}, [open, file, revision, debouncedSettings, projectId]);

const selectProject = (id: string) => {
projectGeneration.current += 1;
Expand Down Expand Up @@ -225,13 +248,28 @@ if open || docked
~ aria-label="Resize Beast DevTools"
~ onPointerDown={(event) => startResize(event, height, setHeight)}
~ )
Topbar(tab={tab} setTab={setTab} suggestionCount={suggestionCount} componentFinderActive={componentFinderActive} componentFinderTitle={componentFinderTitle} setActiveElementTool={setActiveElementTool} elementPickerActive={elementPickerActive} elementPickerTitle={elementPickerTitle} project={project} projectId={projectId} selectProject={selectProject} runtime={runtime} status={status} setOpen={setOpen})
Topbar(
~ tab={tab}
~ setTab={setTab}
~ suggestionCount={suggestionCount}
~ componentFinderActive={componentFinderActive}
~ componentFinderTitle={componentFinderTitle}
~ setActiveElementTool={setActiveElementTool}
~ elementPickerActive={elementPickerActive}
~ elementPickerTitle={elementPickerTitle}
~ project={project}
~ projectId={projectId}
~ selectProject={selectProject}
~ runtime={runtime}
~ status={status}
~ setOpen={setOpen}
~ )
div.bdt-body(role="tabpanel" aria-label={TABS.find((item) => item.id === tab)?.label})
switch tab
case 'components'
ComponentsPanel(
~ components={runtimeProject?.components ?? []}
~ entryComponents={runtimeProject?.entryComponents ?? []}
~ components={runtimeProject?.components ?? EMPTY_COMPONENTS}
~ entryComponents={runtimeProject?.entryComponents ?? EMPTY_STRINGS}
~ view={componentsView}
~ onViewChange={setComponentsView}
~ showControlFlow={showControlFlow}
Expand All @@ -247,7 +285,7 @@ if open || docked
~ projectId={projectId}
~ recent={recent}
~ onRecentChange={setRecent}
~ files={project?.files ?? []}
~ files={project?.files ?? EMPTY_FILES}
~ file={file}
~ report={report}
~ loadError={loadError}
Expand All @@ -258,7 +296,7 @@ if open || docked
RefactorPanel(
~ key={projectId}
~ projectId={projectId}
~ files={project?.files ?? []}
~ files={project?.files ?? EMPTY_FILES}
~ file={file}
~ report={report}
~ loadError={loadError}
Expand Down
62 changes: 37 additions & 25 deletions client/ElementsPanel.btsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'octane'
import { useEffect, useRef, useState } from 'octane'
import { groupAttributes, groupProperties, groupStyles, type ElementSection } from './element-groups.ts'
import { COMMON_STYLES, type ElementInspection, type ElementProperty, type ElementPropertyGroup } from './element-inspector.ts'
import { startLayoutEditor } from './element-layout.ts'
import { COMMON_STYLES, snapshotKey, type ElementInspection, type ElementProperty, type ElementPropertyGroup } from './element-inspector.ts'
import { cx } from './util.ts'

module
Expand Down Expand Up @@ -70,44 +69,55 @@ setup
const [group, setGroup] = useState<ElementPropertyGroup>('styles');
const [query, setQuery] = useState('');
const [showAll, setShowAll] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [newName, setNewName] = useState('');
const [newValue, setNewValue] = useState('');
const [undoCount, setUndoCount] = useState(inspection?.undoCount ?? 0);
const lastKey = useRef<string | null>(null);
const refresh = () => {
setSnapshot(inspection?.capture() ?? null);
setSaving(inspection?.saving ?? false);
setUndoCount(inspection?.undoCount ?? 0);
const next = inspection?.capture() ?? null;
const key = snapshotKey(next);
// Captures are fresh objects every time; only publish when the content
// actually changed, so idle ticks skip the re-render entirely.
if (key === lastKey.current) return;
lastKey.current = key;
setSnapshot(next);
setUndoCount(inspection?.undoCount ?? 0);
};
useEffect(() => {
// A new inspection starts unmatched, even if its content equals the last one.
lastKey.current = null;
refresh();
setError(null);
setError(inspection?.error ?? null);
setQuery('');
if (!inspection) return;
// Attributes, layout and runtime values can change independently of DevTools.
const timer = window.setInterval(refresh, 1000);
return () => window.clearInterval(timer);
const timer = window.setInterval(() => { if (!document.hidden) refresh(); }, 1000);
const unsubscribe = inspection.subscribe(() => { setError(inspection.error); refresh(); });
return () => { window.clearInterval(timer); unsubscribe(); };
}, [inspection]);
// Outline the element on the page, with side handles to resize it.
useEffect(() => inspection ? startLayoutEditor(inspection, refresh) : undefined, [inspection]);
const edit = (name: string, value: string | null) => {
const edit = async (name: string, value: string | null) => {
if (!inspection) return;
try {
inspection.edit(group, name, value);
await inspection.edit(group, name, value);
setError(null);
refresh();
} catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
};
const undo = () => {
const undo = async () => {
if (!inspection) return;
try { inspection.undo(); setError(null); refresh(); }
try { await inspection.undo(); setError(null); refresh(); }
catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
};
const chooseGroup = (next: ElementPropertyGroup) => { setGroup(next); setQuery(''); setNewName(''); setNewValue(''); setError(null); };
const add = (event: SubmitEvent) => {
const add = async (event: SubmitEvent) => {
event.preventDefault();
if (!inspection || !newName.trim()) return;
try {
inspection.edit(group, newName.trim(), newValue);
await inspection.edit(group, newName.trim(), newValue);
setNewName(''); setNewValue(''); setError(null); refresh();
} catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)); }
};
Expand All @@ -132,14 +142,16 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected element")
else
fragment
.bdt-toolbar.bdt-element-toolbar
.bdt-element-identity
strong.bdt-mono #{snapshot.label}
span.bdt-dim #{`${Number(snapshot.width.toFixed(1))} × ${Number(snapshot.height.toFixed(1))} px${snapshot.component ? ' · ' + snapshot.component : ''}`}
.bdt-element-row
strong.bdt-element-identity #{snapshot.label}
span.bdt-mono.bdt-dim #{`${Number(snapshot.width.toFixed(1))} × ${Number(snapshot.height.toFixed(1))} px${snapshot.component ? ' · ' + snapshot.component : ''}`}
.bdt-spacer
span.bdt-element-live Live edits
button.bdt-button(type="button" disabled={undoCount === 0 || !snapshot.connected} onClick={undo}) Undo#{undoCount > 0 ? ' · ' + undoCount : ''}
span.bdt-element-live #{saving ? 'Saving…' : error ? 'Not saved' : 'Saves to source'}
button.bdt-button(type="button" disabled={saving || undoCount === 0} onClick={undo}) Undo#{undoCount > 0 ? ' · ' + undoCount : ''}
button.bdt-button(type="button" onClick={refresh}) Refresh
button.bdt-button-critical(type="button" onClick={onPick}) Pick another
if !snapshot.source
p.bdt-element-notice(role="status") This element has no Beast source location. Enable source tagging to save edits.
if !snapshot.connected
p.bdt-element-notice(role="status") This element was removed from the page. Pick it again to continue editing.
if error !== null
Expand All @@ -150,7 +162,7 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected element")
button(type="button" className={cx('bdt-element-group', group === item.id && 'is-active')} aria-pressed={group === item.id} onClick={() => chooseGroup(item.id)})
span #{item.label}
span.bdt-dim #{snapshot[item.id].length}
p.bdt-element-note Changes apply to the live page. Drag the outline’s sides on the page to resize. Reloading restores the app’s original values.
p.bdt-element-note Edits save to the element’s .btsx file, including layout drags. Changes affect every instance rendered by this source element. The outline stays available when the panel is closed.
.bdt-element-content
.bdt-element-filter
input.bdt-input(type="search" aria-label="Filter element properties" placeholder="Find a property or value…" value={query} onInput={(event) => setQuery(event.currentTarget.value)})
Expand Down Expand Up @@ -178,9 +190,9 @@ section.bdt-pane.bdt-elements-panel(aria-label="Selected element")
if isOpen(category)
div(className={cx('bdt-element-grid', category.columns === 2 && 'is-split')})
each property in category.properties key `${group}:${property.name}`
ElementPropertyRow(property={property} disabled={!snapshot.connected} removable={group === 'attributes' || (group === 'styles' && !!property.inline)} onCommit={edit})
ElementPropertyRow(property={property} disabled={saving || !snapshot.source || !snapshot.connected} removable={group === 'attributes' || (group === 'styles' && !!property.inline)} onCommit={edit})
if group !== 'properties'
form.bdt-element-add(onSubmit={add})
input.bdt-input(aria-label={group === 'styles' ? 'New CSS property' : 'New attribute'} placeholder={group === 'styles' ? 'CSS property' : 'Attribute name'} value={newName} disabled={!snapshot.connected} onInput={(event) => setNewName(event.currentTarget.value)})
input.bdt-input(aria-label="New property value" placeholder="Value" value={newValue} disabled={!snapshot.connected} onInput={(event) => setNewValue(event.currentTarget.value)})
button.bdt-button(type="submit" disabled={!snapshot.connected || !newName.trim()}) Add
input.bdt-input(aria-label={group === 'styles' ? 'New CSS property' : 'New attribute'} placeholder={group === 'styles' ? 'CSS property' : 'Attribute name'} value={newName} disabled={saving || !snapshot.source || !snapshot.connected} onInput={(event) => setNewName(event.currentTarget.value)})
input.bdt-input(aria-label="New property value" placeholder="Value" value={newValue} disabled={saving || !snapshot.source || !snapshot.connected} onInput={(event) => setNewValue(event.currentTarget.value)})
button.bdt-button(type="submit" disabled={saving || !snapshot.source || !snapshot.connected || !newName.trim()}) Add
Loading
Loading