Mobile UX polish: scrollable column, menu workspace section, pin tap fixes
The phone layout had three problems that compounded when running ADC / Pin / Serial demos: the editor refused to shrink past 42vh so panels spilled over a clipped console, the workspace badge was crammed full of buttons, and the IN-pin toggle button was unreliable to tap. - styles.css: on `<= 768px`, make `.main-content` scroll vertically and give the editor a fixed 50vh height (drops to 32vh via `:has()` when a simulator panel is open). All panels + console pin to `flex: 0 0 auto` so flexbox stops squashing them. Inner panel scroll caps tightened to 22vh so two stacked panels don't push the console below the fold. `.pin-toggle` gets `touch-action: manipulation` + `user-select: none` to fix iOS taps inside scrollable parents. - script.js: pin button now has a `pointerup` backup with a 300 ms debounce alongside `click` (Safari sometimes drops `click` on small buttons inside scroll containers). The `⋮` workspace menu auto-closes on outside `pointerdown` as well as `click`, so the open dropdown can't sit on top of the Pin panel and absorb taps. - script.js / index.html / styles.css: move every Workspace action (Export, Import, Reset demos, plus the local-mode-only Folder…, Reconnect, IndexedDB swap, Exit) out of the badge and into a new "Workspace" section in the `⋮` menu. Badge keeps just the storage label. Adds `.menu-separator`, `.menu-section-label`, `.menu-note`, and `.menu-action` styles; removes the now-unused `.workspace-badge-action` / `-exit` / `-note` rules. - bundled-demos/pin_demo.py: pin 4 is now driven exclusively by the IRQ handler, so it stays steady until the IN button is pressed — previously it auto-flashed via on/off in the loop, which made the IRQ effect indistinguishable from the existing animation. The IRQ handler also no longer prints on every press (the panel indicator is the feedback). Cache busters: styles.css 32 -> 36, script.js 57 -> 59. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -147,93 +147,123 @@ class TextEditor {
|
||||
|
||||
async updateWorkspaceBanner() {
|
||||
const badge = document.getElementById('workspace-badge');
|
||||
const menuActions = document.getElementById('workspace-menu-actions');
|
||||
const menuLabel = document.getElementById('workspace-menu-label');
|
||||
if (!badge) return;
|
||||
if (this.localMode) {
|
||||
badge.innerHTML = '';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'workspace-badge-label';
|
||||
label.textContent = 'Local · IndexedDB';
|
||||
badge.appendChild(label);
|
||||
|
||||
if (supportsFolderPicker()) {
|
||||
const pick = document.createElement('button');
|
||||
pick.type = 'button';
|
||||
pick.className = 'workspace-badge-action';
|
||||
pick.textContent = 'Folder…';
|
||||
pick.title = 'Save files to a folder on this device';
|
||||
pick.addEventListener('click', () => this.pickLocalFolder());
|
||||
badge.appendChild(pick);
|
||||
} else {
|
||||
const why = document.createElement('span');
|
||||
why.className = 'workspace-badge-note';
|
||||
why.textContent = '(no folder picker)';
|
||||
why.title =
|
||||
'window.showDirectoryPicker is not exposed in this browser context.\n' +
|
||||
'• Firefox / Safari: not implemented (use Chromium-based browser).\n' +
|
||||
'• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' +
|
||||
'• HTTPS-only requirement: must be served from localhost or https://.\n' +
|
||||
'Files keep saving to IndexedDB; use Export to download a ZIP.';
|
||||
badge.appendChild(why);
|
||||
}
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.type = 'button';
|
||||
exportBtn.className = 'workspace-badge-action';
|
||||
exportBtn.textContent = 'Export';
|
||||
exportBtn.title = 'Download every workspace file as a .zip';
|
||||
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
|
||||
badge.appendChild(exportBtn);
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.type = 'button';
|
||||
importBtn.className = 'workspace-badge-action';
|
||||
importBtn.textContent = 'Import';
|
||||
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
|
||||
importBtn.addEventListener('click', () => this.importWorkspaceZip());
|
||||
badge.appendChild(importBtn);
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.type = 'button';
|
||||
resetBtn.className = 'workspace-badge-action';
|
||||
resetBtn.textContent = 'Reset demos';
|
||||
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
|
||||
resetBtn.addEventListener('click', () => this.resetDemoFiles());
|
||||
badge.appendChild(resetBtn);
|
||||
|
||||
const exit = document.createElement('button');
|
||||
exit.type = 'button';
|
||||
exit.className = 'workspace-badge-exit';
|
||||
exit.textContent = 'Exit';
|
||||
exit.title = 'Leave local mode and return to the home page';
|
||||
exit.addEventListener('click', () => {
|
||||
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
|
||||
exitLocalMode();
|
||||
window.location.href = '/';
|
||||
// Helper: a button styled as a menu item.
|
||||
const makeMenuButton = (text, title, onClick, opts = {}) => {
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'menu-item menu-action' + (opts.danger ? ' menu-action-danger' : '');
|
||||
b.setAttribute('role', 'menuitem');
|
||||
b.textContent = text;
|
||||
if (title) b.title = title;
|
||||
b.addEventListener('click', (event) => {
|
||||
// Close the dropdown after a click — feels more like a normal menu.
|
||||
const details = document.getElementById('header-menu');
|
||||
if (details) details.open = false;
|
||||
onClick(event);
|
||||
});
|
||||
badge.appendChild(exit);
|
||||
badge.classList.remove('hidden');
|
||||
return b;
|
||||
};
|
||||
|
||||
// Helper: a passive note line inside the menu (e.g. for "no folder picker").
|
||||
const makeMenuNote = (text, title) => {
|
||||
const n = document.createElement('div');
|
||||
n.className = 'menu-note';
|
||||
n.textContent = text;
|
||||
if (title) n.title = title;
|
||||
return n;
|
||||
};
|
||||
|
||||
badge.innerHTML = '';
|
||||
if (menuActions) menuActions.innerHTML = '';
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.className = 'workspace-badge-label';
|
||||
badge.appendChild(label);
|
||||
badge.classList.remove('hidden');
|
||||
|
||||
if (this.localMode) {
|
||||
label.textContent = 'Local · IndexedDB';
|
||||
if (menuLabel) menuLabel.textContent = 'Workspace · Local';
|
||||
|
||||
if (menuActions) {
|
||||
if (supportsFolderPicker()) {
|
||||
menuActions.appendChild(
|
||||
makeMenuButton('📁 Save to folder…', 'Save files to a folder on this device', () =>
|
||||
this.pickLocalFolder(),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
menuActions.appendChild(
|
||||
makeMenuNote(
|
||||
'(folder picker unavailable)',
|
||||
'window.showDirectoryPicker is not exposed in this browser context.\n' +
|
||||
'• Firefox / Safari: not implemented (use a Chromium-based browser).\n' +
|
||||
'• Brave: enable brave://flags/#file-system-access-api or relax Shields for this site.\n' +
|
||||
'• HTTPS-only requirement: must be served from localhost or https://.\n' +
|
||||
'Files keep saving to IndexedDB; use Export to download a ZIP.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
menuActions.appendChild(
|
||||
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
|
||||
this.exportWorkspaceZip(),
|
||||
),
|
||||
);
|
||||
menuActions.appendChild(
|
||||
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
|
||||
this.importWorkspaceZip(),
|
||||
),
|
||||
);
|
||||
menuActions.appendChild(
|
||||
makeMenuButton(
|
||||
'↻ Reset demos',
|
||||
'Re-copy the bundled demos into code/ (overwrites your edits to those files)',
|
||||
() => this.resetDemoFiles(),
|
||||
),
|
||||
);
|
||||
menuActions.appendChild(
|
||||
makeMenuButton(
|
||||
'🚪 Exit local mode',
|
||||
'Leave local mode and return to the home page',
|
||||
() => {
|
||||
if (!confirm('Leave local mode? Your files stay in this browser; you can come back later.')) return;
|
||||
exitLocalMode();
|
||||
window.location.href = '/';
|
||||
},
|
||||
{ danger: true },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const info = await this.localWorkspace.describeStorage();
|
||||
if (info) {
|
||||
if (info.mode === 'filesystem') {
|
||||
label.textContent = `Local · ${info.label}`;
|
||||
const swap = document.createElement('button');
|
||||
swap.type = 'button';
|
||||
swap.className = 'workspace-badge-action';
|
||||
swap.textContent = 'IndexedDB';
|
||||
swap.title = 'Switch storage back to in-browser IndexedDB';
|
||||
swap.addEventListener('click', () => this.useIndexedDbStorage());
|
||||
badge.insertBefore(swap, exit);
|
||||
if (menuActions) {
|
||||
const swap = makeMenuButton(
|
||||
'↺ Switch to IndexedDB',
|
||||
'Switch storage back to in-browser IndexedDB',
|
||||
() => this.useIndexedDbStorage(),
|
||||
);
|
||||
// Insert near the top, just under the picker button (or first child).
|
||||
menuActions.insertBefore(swap, menuActions.firstChild);
|
||||
}
|
||||
} else if (info.pendingReconnect) {
|
||||
label.textContent = `Local · IndexedDB (folder “${info.pendingFolderName}” needs reconnect)`;
|
||||
const reconnect = document.createElement('button');
|
||||
reconnect.type = 'button';
|
||||
reconnect.className = 'workspace-badge-action';
|
||||
reconnect.textContent = 'Reconnect';
|
||||
reconnect.title = 'Re-grant read/write access to the previously picked folder';
|
||||
reconnect.addEventListener('click', () => this.reconnectLocalFolder());
|
||||
badge.insertBefore(reconnect, exit);
|
||||
label.textContent = `Local · folder “${info.pendingFolderName}” (reconnect)`;
|
||||
if (menuActions) {
|
||||
const reconnect = makeMenuButton(
|
||||
`🔌 Reconnect “${info.pendingFolderName}”`,
|
||||
'Re-grant read/write access to the previously picked folder',
|
||||
() => this.reconnectLocalFolder(),
|
||||
);
|
||||
menuActions.insertBefore(reconnect, menuActions.firstChild);
|
||||
}
|
||||
} else {
|
||||
label.textContent = 'Local · IndexedDB';
|
||||
}
|
||||
@@ -243,39 +273,31 @@ class TextEditor {
|
||||
}
|
||||
return;
|
||||
}
|
||||
badge.innerHTML = '';
|
||||
const label = document.createElement('span');
|
||||
label.className = 'workspace-badge-label';
|
||||
|
||||
label.textContent = this.workspaceUserId
|
||||
? `Workspace: user ${this.workspaceUserId}`
|
||||
: 'Server workspace';
|
||||
badge.appendChild(label);
|
||||
if (menuLabel) menuLabel.textContent = 'Workspace';
|
||||
|
||||
const exportBtn = document.createElement('button');
|
||||
exportBtn.type = 'button';
|
||||
exportBtn.className = 'workspace-badge-action';
|
||||
exportBtn.textContent = 'Export';
|
||||
exportBtn.title = 'Download every workspace file as a .zip';
|
||||
exportBtn.addEventListener('click', () => this.exportWorkspaceZip());
|
||||
badge.appendChild(exportBtn);
|
||||
|
||||
const importBtn = document.createElement('button');
|
||||
importBtn.type = 'button';
|
||||
importBtn.className = 'workspace-badge-action';
|
||||
importBtn.textContent = 'Import';
|
||||
importBtn.title = 'Upload a .zip — its files land in code/ (overwrites on conflict)';
|
||||
importBtn.addEventListener('click', () => this.importWorkspaceZip());
|
||||
badge.appendChild(importBtn);
|
||||
|
||||
const resetBtn = document.createElement('button');
|
||||
resetBtn.type = 'button';
|
||||
resetBtn.className = 'workspace-badge-action';
|
||||
resetBtn.textContent = 'Reset demos';
|
||||
resetBtn.title = 'Re-copy the bundled demos into code/ (overwrites your edits to those files)';
|
||||
resetBtn.addEventListener('click', () => this.resetDemoFiles());
|
||||
badge.appendChild(resetBtn);
|
||||
|
||||
badge.classList.remove('hidden');
|
||||
if (menuActions) {
|
||||
menuActions.appendChild(
|
||||
makeMenuButton('⬇️ Export workspace…', 'Download every workspace file as a .zip', () =>
|
||||
this.exportWorkspaceZip(),
|
||||
),
|
||||
);
|
||||
menuActions.appendChild(
|
||||
makeMenuButton('⬆️ Import .zip…', 'Upload a .zip — its files land in code/ (overwrites on conflict)', () =>
|
||||
this.importWorkspaceZip(),
|
||||
),
|
||||
);
|
||||
menuActions.appendChild(
|
||||
makeMenuButton(
|
||||
'↻ Reset demos',
|
||||
'Re-copy the bundled demos into code/ (overwrites your edits to those files)',
|
||||
() => this.resetDemoFiles(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async resetDemoFiles() {
|
||||
@@ -1361,12 +1383,18 @@ class TextEditor {
|
||||
setupHeaderMenu() {
|
||||
const menu = document.getElementById('header-menu');
|
||||
if (!menu) return;
|
||||
document.addEventListener('click', (event) => {
|
||||
/* Close the dropdown when the user taps anywhere outside it. We listen
|
||||
* on both `pointerdown` (fires reliably for touch) and `click` (mouse
|
||||
* users + keyboard activations) so the menu can't end up floating over
|
||||
* the Pin / ADC / Serial panels on mobile. */
|
||||
const closeIfOutside = (event) => {
|
||||
if (!menu.open) return;
|
||||
if (!menu.contains(event.target)) {
|
||||
menu.open = false;
|
||||
}
|
||||
});
|
||||
const target = event.target;
|
||||
if (target instanceof Node && menu.contains(target)) return;
|
||||
menu.open = false;
|
||||
};
|
||||
document.addEventListener('pointerdown', closeIfOutside);
|
||||
document.addEventListener('click', closeIfOutside);
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape' && menu.open) {
|
||||
menu.open = false;
|
||||
@@ -2433,7 +2461,8 @@ class TextEditor {
|
||||
button.type = 'button';
|
||||
button.className = 'pin-toggle';
|
||||
button.textContent = '0';
|
||||
button.addEventListener('click', () => {
|
||||
const togglePin = (event) => {
|
||||
if (event && typeof event.preventDefault === 'function') event.preventDefault();
|
||||
const cur = this.pinInView ? (this.pinInView[pin] | 0) : (this._pinInLocal[pin] || 0);
|
||||
const next = cur ? 0 : 1;
|
||||
this._pinInLocal[pin] = next;
|
||||
@@ -2460,7 +2489,26 @@ class TextEditor {
|
||||
}
|
||||
button.textContent = next ? '1' : '0';
|
||||
button.classList.toggle('on', Boolean(next));
|
||||
};
|
||||
/* On iOS Safari, `click` on a small button inside a scrollable parent
|
||||
* sometimes fails to fire (the tap gets reclassified as a scroll
|
||||
* gesture). `pointerup` fires reliably for finger taps; we guard with
|
||||
* a 300ms suppression flag so we don't double-toggle on browsers that
|
||||
* deliver both events. */
|
||||
let lastTriggerAt = 0;
|
||||
const trigger = (event) => {
|
||||
const now = (typeof performance !== 'undefined' && performance.now)
|
||||
? performance.now()
|
||||
: Date.now();
|
||||
if (now - lastTriggerAt < 300) return;
|
||||
lastTriggerAt = now;
|
||||
togglePin(event);
|
||||
};
|
||||
button.addEventListener('pointerup', (event) => {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
trigger(event);
|
||||
});
|
||||
button.addEventListener('click', trigger);
|
||||
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'pin-pwm-bar';
|
||||
|
||||
Reference in New Issue
Block a user