Sequence editor. Layout fixes
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input)
|
- Sequence editor (sequences stored in sequences.json up to boards and in browser local storage for individual boards, manual XY input). Fully stateless
|
||||||
Both of these should also have effects like: Normal movement, erratic, jumpy, random
|
Both of these should also have effects like: Normal movement, erratic, jumpy, random
|
||||||
- No Disable steppers but
|
- No Disable steppers but
|
||||||
- Idle timeout
|
- Idle timeout
|
||||||
|
|||||||
@@ -374,6 +374,11 @@ The `?v=Date.now()` on imports handles this automatically on page load.
|
|||||||
- Text input to spell words automatically
|
- Text input to spell words automatically
|
||||||
- Delay, Speed, and Acceleration sliders
|
- Delay, Speed, and Acceleration sliders
|
||||||
- Position overlay marker on background
|
- Position overlay marker on background
|
||||||
|
- [x] Sequence Editor section (`sequence-editor.js`) & Runner:
|
||||||
|
- Vertical list builder for custom sequences (Spots or X/Y).
|
||||||
|
- JSON export to persist sequences to `sequences.json`.
|
||||||
|
- Quick Sequences UI in `stepper-test`, `board-control`, and `input-text`.
|
||||||
|
- Shared `sequence-runner.js` to handle asynchronous execution and delays.
|
||||||
- [x] Firmware (`src/main.cpp`) — AccelStepper + BLE command parser + position NOTIFY
|
- [x] Firmware (`src/main.cpp`) — AccelStepper + BLE command parser + position NOTIFY
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -393,10 +398,11 @@ The router currently only has three routes. These sections need to be created/ex
|
|||||||
- Requires background `spots.json` to have `"hasAlphabet": true`.
|
- Requires background `spots.json` to have `"hasAlphabet": true`.
|
||||||
- Persisted Speed, Acceleration, and Delay settings.
|
- Persisted Speed, Acceleration, and Delay settings.
|
||||||
|
|
||||||
### `sequence-editor` section (not started)
|
### `sequence-editor` section (implemented)
|
||||||
- Record and replay a sequence of moves
|
- Record and replay a sequence of moves.
|
||||||
- Save/load sequences to localStorage
|
- Draft sequences auto-save to `localStorage`.
|
||||||
- Simple list UI: add step, delete step, reorder, run
|
- Simple list UI: add Spot step, add XY step, delete step, reorder, play sequence.
|
||||||
|
- Export as JSON to paste into `web/assets/backgrounds/<bg>/sequences.json`.
|
||||||
|
|
||||||
To add a new section:
|
To add a new section:
|
||||||
1. Create `web/js/sections/<name>.js` exporting `{ mount(el), unmount() }`
|
1. Create `web/js/sections/<name>.js` exporting `{ mount(el), unmount() }`
|
||||||
@@ -421,6 +427,7 @@ To add a new section:
|
|||||||
| **`fivebarIKGame.js` is reference only** | The original site's game uses a different angle convention (absolute degrees, different home, with `ikElbowSigns` tracking). We use the C++ IK formula instead because it directly produces step deltas from home. `fivebarIKGame.js` is kept in the repo for reference and understanding, not imported. |
|
| **`fivebarIKGame.js` is reference only** | The original site's game uses a different angle convention (absolute degrees, different home, with `ikElbowSigns` tracking). We use the C++ IK formula instead because it directly produces step deltas from home. `fivebarIKGame.js` is kept in the repo for reference and understanding, not imported. |
|
||||||
| **Hash-based routing** | Keeps the SPA working from `file://` and simple static servers without needing a history API setup |
|
| **Hash-based routing** | Keeps the SPA working from `file://` and simple static servers without needing a history API setup |
|
||||||
| **Persistent Homing State** | The UI uses `localStorage.getItem('wiji_homed')` to track if the arm has been homed during the user's ongoing interaction. This avoids forcing the user to re-home every time they switch tabs or pages. Sending `HOMEALL` sets this to `true`. |
|
| **Persistent Homing State** | The UI uses `localStorage.getItem('wiji_homed')` to track if the arm has been homed during the user's ongoing interaction. This avoids forcing the user to re-home every time they switch tabs or pages. Sending `HOMEALL` sets this to `true`. |
|
||||||
|
| **Stateless Sequence Storage** | To remain serverless, sequences are stored per-background in `web/assets/backgrounds/<bg>/sequences.json`. The Sequence Editor generates the JSON payload, which the user manually copy-pastes into the file to persist. Sequences marked `"favorite": true` appear automatically in Quick Sequences cards across the app. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+10
-9
@@ -46,28 +46,27 @@
|
|||||||
Input Text
|
Input Text
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a class="sidebar-nav-item" id="nav-sequence-editor" href="#sequence-editor" data-route="sequence-editor" role="button"
|
||||||
|
aria-label="Go to Sequence Editor">
|
||||||
|
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
|
||||||
|
Sequence Editor
|
||||||
|
</a>
|
||||||
|
|
||||||
<a class="sidebar-nav-item" id="nav-stepper-test" href="#stepper-test" data-route="stepper-test" role="button"
|
<a class="sidebar-nav-item" id="nav-stepper-test" href="#stepper-test" data-route="stepper-test" role="button"
|
||||||
aria-label="Go to Stepper Test">
|
aria-label="Go to Stepper Test">
|
||||||
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-stepper"></use></svg>
|
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-stepper"></use></svg>
|
||||||
Stepper Test
|
Stepper Test
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Spacer + coming soon -->
|
<!-- Spacer -->
|
||||||
<div style="flex:1"></div>
|
<div style="flex:1"></div>
|
||||||
<div class="divider"></div>
|
|
||||||
<span class="sidebar-section-label">Coming Soon</span>
|
|
||||||
|
|
||||||
<div class="sidebar-nav-item" style="opacity:0.35;cursor:default" aria-disabled="true">
|
|
||||||
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
|
|
||||||
Sequence Editor
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- ── Main area ──────────────────────────────────────────────── -->
|
<!-- ── Main area ──────────────────────────────────────────────── -->
|
||||||
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
<div style="display:flex;flex-direction:column;flex:1;overflow:hidden">
|
||||||
|
|
||||||
<!-- Top bar -->
|
<!-- Top bar -->
|
||||||
<header class="topbar" role="banner">
|
<header class="top-bar" style="display:flex; justify-content:space-between; align-items:center; padding: 12px 10px; background: var(--surface-1); border-bottom: 1px solid var(--border);" role="banner">
|
||||||
<!-- Hamburger -->
|
<!-- Hamburger -->
|
||||||
<button class="hamburger" id="hamburger-btn" aria-label="Toggle sidebar" aria-expanded="true"
|
<button class="hamburger" id="hamburger-btn" aria-label="Toggle sidebar" aria-expanded="true"
|
||||||
aria-controls="sidebar">
|
aria-controls="sidebar">
|
||||||
@@ -117,12 +116,14 @@
|
|||||||
import StepperSection from './web/js/sections/stepper-test.js';
|
import StepperSection from './web/js/sections/stepper-test.js';
|
||||||
import BoardControlSection from './web/js/sections/board-control.js';
|
import BoardControlSection from './web/js/sections/board-control.js';
|
||||||
import InputTextSection from './web/js/sections/input-text.js';
|
import InputTextSection from './web/js/sections/input-text.js';
|
||||||
|
import SequenceEditorSection from './web/js/sections/sequence-editor.js';
|
||||||
|
|
||||||
// ── Register routes ────────────────────────────────────────────
|
// ── Register routes ────────────────────────────────────────────
|
||||||
Router.register('home', HomeSection);
|
Router.register('home', HomeSection);
|
||||||
Router.register('stepper-test', StepperSection);
|
Router.register('stepper-test', StepperSection);
|
||||||
Router.register('board-control', BoardControlSection);
|
Router.register('board-control', BoardControlSection);
|
||||||
Router.register('input-text', InputTextSection);
|
Router.register('input-text', InputTextSection);
|
||||||
|
Router.register('sequence-editor', SequenceEditorSection);
|
||||||
|
|
||||||
// ── Sidebar toggle logic ───────────────────────────────────────
|
// ── Sidebar toggle logic ───────────────────────────────────────
|
||||||
const sidebar = document.getElementById('sidebar');
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "seq_alphabet_default",
|
||||||
|
"name": "Spell Alphabet",
|
||||||
|
"favorite": true,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "spot", "id": "A" },
|
||||||
|
{ "type": "spot", "id": "B" },
|
||||||
|
{ "type": "spot", "id": "C" },
|
||||||
|
{ "type": "spot", "id": "D" },
|
||||||
|
{ "type": "spot", "id": "E" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seq_yes_no",
|
||||||
|
"name": "Yes then No",
|
||||||
|
"favorite": true,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "spot", "id": "YES" },
|
||||||
|
{ "type": "spot", "id": "NO" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "seq_corners",
|
||||||
|
"name": "Four Corners",
|
||||||
|
"favorite": true,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "xy", "x": 130, "y": 120, "delay": 1500 },
|
||||||
|
{ "type": "xy", "x": -130, "y": 120, "delay": 1500 },
|
||||||
|
{ "type": "xy", "x": -130, "y": -15, "delay": 1500 },
|
||||||
|
{ "type": "xy", "x": 130, "y": -15, "delay": 1500 },
|
||||||
|
{ "type": "xy", "x": 0, "y": 50, "delay": 1500 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "seq_alphabet_portrait",
|
||||||
|
"name": "Spell Alphabet (Portrait)",
|
||||||
|
"favorite": true,
|
||||||
|
"steps": [
|
||||||
|
{ "type": "spot", "id": "A" },
|
||||||
|
{ "type": "spot", "id": "B" },
|
||||||
|
{ "type": "spot", "id": "C", "delay": 1000 },
|
||||||
|
{ "type": "spot", "id": "D" },
|
||||||
|
{ "type": "spot", "id": "E" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Sequence Configuration Specification (`sequences.json`)
|
||||||
|
|
||||||
|
Sequences allow predefined orchestration of movements. They are stored per-background in `web/assets/backgrounds/<bg_name>/sequences.json`.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
The file should contain a JSON array of sequence objects.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "demo_sequence",
|
||||||
|
"name": "Demo Mix",
|
||||||
|
"favorite": true,
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "spot",
|
||||||
|
"id": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "spot",
|
||||||
|
"id": "B",
|
||||||
|
"delay": 1500
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "xy",
|
||||||
|
"x": 50,
|
||||||
|
"y": 120,
|
||||||
|
"delay": 500
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sequence Object Properties
|
||||||
|
- `id` (string): Unique identifier for the sequence.
|
||||||
|
- `name` (string): Human-readable name displayed in the UI.
|
||||||
|
- `favorite` (boolean): If `true`, this sequence will appear in the "Quick Sequences" box across relevant sections (`board-control`, `input-text`, `stepper-test`).
|
||||||
|
- `steps` (array): A list of step objects executed in order.
|
||||||
|
|
||||||
|
## Step Object Properties
|
||||||
|
All steps share an optional `delay` property. If omitted, the global delay setting is used.
|
||||||
|
- `delay` (number, optional): Delay in milliseconds to wait *after* reaching the target before moving to the next step.
|
||||||
|
|
||||||
|
### Type: "spot"
|
||||||
|
Moves to a predefined spot in the background's `spots.json`.
|
||||||
|
- `type`: Must be `"spot"`.
|
||||||
|
- `id`: The spot ID string (e.g., `"YES"`, `"A"`).
|
||||||
|
|
||||||
|
### Type: "xy"
|
||||||
|
Moves to a raw X/Y coordinate in mm.
|
||||||
|
- `type`: Must be `"xy"`.
|
||||||
|
- `x`: X coordinate (number).
|
||||||
|
- `y`: Y coordinate (number).
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import SequenceRunner from './sequence-runner.js';
|
||||||
|
import UI from './ui.js';
|
||||||
|
|
||||||
|
export function buildQuickSequencesHTML() {
|
||||||
|
return `
|
||||||
|
<div class="card" id="quick-sequences-card" style="margin-bottom: 16px;">
|
||||||
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span class="card-title">Quick Sequences</span>
|
||||||
|
<button id="btn-stop-sequence" class="btn btn-sm btn-danger" style="display:none;">STOP</button>
|
||||||
|
</div>
|
||||||
|
<div id="quick-sequences-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">
|
||||||
|
Loading sequences...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function initQuickSequences(bgName, getSpots, getSteps, onStepComplete) {
|
||||||
|
const listEl = document.getElementById('quick-sequences-list');
|
||||||
|
const stopBtn = document.getElementById('btn-stop-sequence');
|
||||||
|
if (!listEl) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`web/assets/backgrounds/${bgName}/sequences.json?v=${Date.now()}`);
|
||||||
|
if (!res.ok) throw new Error('Not found');
|
||||||
|
const sequences = await res.json();
|
||||||
|
const favorites = sequences.filter(s => s.favorite);
|
||||||
|
|
||||||
|
if (favorites.length === 0) {
|
||||||
|
listEl.innerHTML = `<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">No favorite sequences found.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = favorites.map(seq => `
|
||||||
|
<div class="sequence-item" style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border);">
|
||||||
|
<span style="font-weight:bold; font-size:1rem;">${seq.name}</span>
|
||||||
|
<button class="btn btn-sm btn-success btn-play-seq" data-seq-id="${seq.id}" title="Play Sequence" style="border-radius: 50%; width: 32px; height: 32px; padding: 0; display:flex; align-items:center; justify-content:center;">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
listEl.querySelectorAll('.btn-play-seq').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const seq = favorites.find(s => s.id === btn.dataset.seqId);
|
||||||
|
if (seq) {
|
||||||
|
stopBtn.style.display = 'block';
|
||||||
|
const { steps1, steps2 } = getSteps();
|
||||||
|
await SequenceRunner.run(seq, getSpots(), steps1, steps2, onStepComplete);
|
||||||
|
stopBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
stopBtn.addEventListener('click', () => {
|
||||||
|
SequenceRunner.stop();
|
||||||
|
stopBtn.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<div style="padding: 12px; text-align: center; color: var(--text-muted); font-size: 0.9rem;">No sequences.json found for this background.</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import BLE from '../ble.js';
|
|||||||
import UI from '../ui.js';
|
import UI from '../ui.js';
|
||||||
import IK from '../kinematics.js';
|
import IK from '../kinematics.js';
|
||||||
import Settings from '../settings.js';
|
import Settings from '../settings.js';
|
||||||
|
import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js';
|
||||||
|
|
||||||
let eventCleanup = [];
|
let eventCleanup = [];
|
||||||
let isHomed = false;
|
let isHomed = false;
|
||||||
@@ -147,6 +148,8 @@ function buildHTML() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${buildQuickSequencesHTML()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -317,6 +320,15 @@ export default {
|
|||||||
currentBg = bgSelect.value;
|
currentBg = bgSelect.value;
|
||||||
localStorage.setItem('wiji_bg', currentBg);
|
localStorage.setItem('wiji_bg', currentBg);
|
||||||
loadSpots();
|
loadSpots();
|
||||||
|
initQuickSequences(
|
||||||
|
currentBg,
|
||||||
|
() => spots,
|
||||||
|
() => ({ steps1, steps2 }),
|
||||||
|
(s1, s2) => {
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
updatePositionReadout();
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,6 +411,16 @@ export default {
|
|||||||
updatePositionReadout();
|
updatePositionReadout();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initQuickSequences(
|
||||||
|
currentBg,
|
||||||
|
() => spots,
|
||||||
|
() => ({ steps1, steps2 }),
|
||||||
|
(s1, s2) => {
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
updatePositionReadout();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -81,9 +81,19 @@ const HomeSection = {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button class="sidebar-nav-item" data-route="sequence-editor"
|
||||||
|
onclick="document.getElementById('nav-sequence-editor').click()"
|
||||||
|
style="padding:14px 16px;background:var(--surface-2);text-align:left;border:none;cursor:pointer;width:100%;">
|
||||||
|
<svg class="nav-icon"><use href="web/assets/icons.svg#icon-sequence"></use></svg>
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:600;font-size:0.875rem;color:var(--text-primary)">Sequence Editor</div>
|
||||||
|
<div style="font-size:0.75rem;color:var(--text-muted);margin-top:2px">Build and test custom sequences</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<button class="sidebar-nav-item" data-route="stepper-test"
|
<button class="sidebar-nav-item" data-route="stepper-test"
|
||||||
onclick="document.getElementById('nav-stepper-test').click()"
|
onclick="document.getElementById('nav-stepper-test').click()"
|
||||||
style="padding:14px 16px;background:var(--surface-2)">
|
style="padding:14px 16px;background:var(--surface-2);text-align:left;border:none;cursor:pointer;width:100%;">
|
||||||
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg class="nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
<circle cx="12" cy="12" r="3"/><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import BLE from '../ble.js';
|
|||||||
import UI from '../ui.js';
|
import UI from '../ui.js';
|
||||||
import IK from '../kinematics.js';
|
import IK from '../kinematics.js';
|
||||||
import Settings from '../settings.js';
|
import Settings from '../settings.js';
|
||||||
|
import { buildQuickSequencesHTML, initQuickSequences } from '../quick-sequences.js';
|
||||||
|
|
||||||
let eventCleanup = [];
|
let eventCleanup = [];
|
||||||
let isHomed = false;
|
let isHomed = false;
|
||||||
@@ -141,10 +142,10 @@ function buildHTML() {
|
|||||||
<input type="range" id="slider-accel" min="50" max="1000" step="50" value="${Settings.accel}" style="width:100%; margin-bottom: 8px;">
|
<input type="range" id="slider-accel" min="50" max="1000" step="50" value="${Settings.accel}" style="width:100%; margin-bottom: 8px;">
|
||||||
|
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<label class="form-label" style="margin:0;">Delay (s)</label>
|
<label class="form-label" style="margin:0;">Global Delay (ms)</label>
|
||||||
<span id="delay-val" style="font-size: 0.8rem; color: var(--text-muted);">${(Settings.delay/1000).toFixed(1)}</span>
|
<span id="delay-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.delay}</span>
|
||||||
</div>
|
</div>
|
||||||
<input type="range" id="slider-delay" min="500" max="5000" step="500" value="${Settings.delay}" style="width:100%;">
|
<input type="range" id="slider-delay" min="500" max="5000" step="100" value="${Settings.delay}" style="width:100%;">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 12px;">
|
<div class="form-group" style="margin-bottom: 12px;">
|
||||||
@@ -160,6 +161,8 @@ function buildHTML() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
${buildQuickSequencesHTML()}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,6 +365,15 @@ export default {
|
|||||||
currentBg = bgSelect.value;
|
currentBg = bgSelect.value;
|
||||||
localStorage.setItem('wiji_input_bg', currentBg);
|
localStorage.setItem('wiji_input_bg', currentBg);
|
||||||
loadSpots();
|
loadSpots();
|
||||||
|
initQuickSequences(
|
||||||
|
currentBg,
|
||||||
|
() => spots,
|
||||||
|
() => ({ steps1, steps2 }),
|
||||||
|
(s1, s2) => {
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
updatePosMarker();
|
||||||
|
}
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,7 +396,7 @@ export default {
|
|||||||
|
|
||||||
sDelay.addEventListener('input', (e) => {
|
sDelay.addEventListener('input', (e) => {
|
||||||
Settings.delay = parseInt(e.target.value);
|
Settings.delay = parseInt(e.target.value);
|
||||||
document.getElementById('delay-val').textContent = (Settings.delay/1000).toFixed(1);
|
document.getElementById('delay-val').textContent = Settings.delay;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Homing Logic ──────────────────────────────────────────
|
// ── Homing Logic ──────────────────────────────────────────
|
||||||
@@ -450,6 +462,16 @@ export default {
|
|||||||
updatePosMarker();
|
updatePosMarker();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initQuickSequences(
|
||||||
|
currentBg,
|
||||||
|
() => spots,
|
||||||
|
() => ({ steps1, steps2 }),
|
||||||
|
(s1, s2) => {
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
updatePosMarker();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,738 @@
|
|||||||
|
import BLE from '../ble.js';
|
||||||
|
import UI from '../ui.js';
|
||||||
|
import IK from '../kinematics.js';
|
||||||
|
import Settings from '../settings.js';
|
||||||
|
import SequenceRunner from '../sequence-runner.js';
|
||||||
|
|
||||||
|
let eventCleanup = [];
|
||||||
|
let isHomed = false;
|
||||||
|
let spots = [];
|
||||||
|
let bounds = { xMin: -150, xMax: 150, yMin: -30, yMax: 145 };
|
||||||
|
let currentBg = localStorage.getItem('wiji_bg') || 'default';
|
||||||
|
let steps1 = 0;
|
||||||
|
let steps2 = 0;
|
||||||
|
let hasAlphabet = false;
|
||||||
|
let savedBrowserSequences = [];
|
||||||
|
let bgSequences = [];
|
||||||
|
let sequence = {
|
||||||
|
id: "my_custom_sequence",
|
||||||
|
name: "My Sequence",
|
||||||
|
favorite: false,
|
||||||
|
steps: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function syncStateFromStorage() {
|
||||||
|
isHomed = localStorage.getItem('wiji_homed') === 'true';
|
||||||
|
steps1 = isHomed ? parseInt(localStorage.getItem('wiji_steps1') || IK.ARM.HOME_STEPS.m1) : IK.ARM.HOME_STEPS.m1;
|
||||||
|
steps2 = isHomed ? parseInt(localStorage.getItem('wiji_steps2') || IK.ARM.HOME_STEPS.m2) : IK.ARM.HOME_STEPS.m2;
|
||||||
|
|
||||||
|
const savedSeq = localStorage.getItem('wiji_sequence_draft');
|
||||||
|
if (savedSeq) {
|
||||||
|
try {
|
||||||
|
sequence = JSON.parse(savedSeq);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
const savedSeqs = localStorage.getItem('wiji_saved_sequences');
|
||||||
|
if (savedSeqs) {
|
||||||
|
try {
|
||||||
|
savedBrowserSequences = JSON.parse(savedSeqs);
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSteps() {
|
||||||
|
if (isHomed) {
|
||||||
|
localStorage.setItem('wiji_steps1', steps1);
|
||||||
|
localStorage.setItem('wiji_steps2', steps2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSequenceDraft() {
|
||||||
|
localStorage.setItem('wiji_sequence_draft', JSON.stringify(sequence));
|
||||||
|
updateJSONPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJSONPreview() {
|
||||||
|
const el = document.getElementById('json-preview');
|
||||||
|
if (el) {
|
||||||
|
el.value = JSON.stringify([sequence], null, 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToBrowser() {
|
||||||
|
const existingIdx = savedBrowserSequences.findIndex(s => s.id === sequence.id);
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
savedBrowserSequences[existingIdx] = JSON.parse(JSON.stringify(sequence));
|
||||||
|
} else {
|
||||||
|
savedBrowserSequences.push(JSON.parse(JSON.stringify(sequence)));
|
||||||
|
}
|
||||||
|
localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences));
|
||||||
|
renderSavedSequences();
|
||||||
|
UI.log(`Saved '${sequence.name}' to browser storage`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHTML() {
|
||||||
|
return `
|
||||||
|
<style>
|
||||||
|
.board-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 300px;
|
||||||
|
gap: 16px;
|
||||||
|
height: 100%;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) { .board-grid { grid-template-columns: 1fr; } }
|
||||||
|
.homing-banner {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.homing-banner.homed {
|
||||||
|
background: rgba(0, 230, 118, 0.1);
|
||||||
|
color: var(--accent-green);
|
||||||
|
border: 1px solid rgba(0, 230, 118, 0.3);
|
||||||
|
}
|
||||||
|
.homing-banner.not-homed {
|
||||||
|
background: rgba(255, 82, 82, 0.1);
|
||||||
|
color: var(--accent-red, #ff5252);
|
||||||
|
border: 1px solid rgba(255, 82, 82, 0.3);
|
||||||
|
}
|
||||||
|
.step-box {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.step-controls { display: flex; gap: 6px; }
|
||||||
|
.btn-icon {
|
||||||
|
padding: 4px; border-radius: var(--radius-xs);
|
||||||
|
background: transparent; color: var(--text-muted); border: none; cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn-icon:hover { color: var(--text-primary); background: rgba(255,255,255,0.1); }
|
||||||
|
.setting-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 2fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.step-input-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.step-input-row.four-cols {
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
}
|
||||||
|
.mobile-separator { display: none; }
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.meta-row { grid-template-columns: 1fr auto; }
|
||||||
|
.meta-row .name-input { grid-column: 1 / -1; order: 3; }
|
||||||
|
.step-input-row { grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.step-input-row .full-w-mobile { grid-column: 1 / -1; }
|
||||||
|
.step-input-row.four-cols { grid-template-columns: 1fr 1fr; }
|
||||||
|
.mobile-separator { display: block; height: 1px; background: rgba(255,255,255,0.05); margin: 16px 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="section-page fade-in" style="display:flex; flex-direction:column; height: 100%;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Sequence Editor</h2>
|
||||||
|
<p>Build and test custom sequences. Export JSON to save them permanently.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-grid">
|
||||||
|
<!-- ── Editor Area ─────────────────────────────────────── -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap: 12px;">
|
||||||
|
|
||||||
|
<div class="card" style="flex:1;">
|
||||||
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||||
|
<span class="card-title">Sequence Steps</span>
|
||||||
|
<div style="display:flex; gap:4px; flex-wrap:wrap;">
|
||||||
|
<button id="btn-play-editor" class="btn btn-sm btn-success">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align: middle; margin-right: 4px;"><path d="M8 5v14l11-7z"/></svg>PLAY
|
||||||
|
</button>
|
||||||
|
<button id="btn-stop-editor" class="btn btn-sm btn-danger" style="display:none;">STOP</button>
|
||||||
|
<button id="btn-clear-seq" class="btn btn-sm btn-ghost">CLEAR</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sequence Meta Info -->
|
||||||
|
<div class="meta-row">
|
||||||
|
<input type="text" id="seq-id" class="form-input" placeholder="Sequence ID" value="${sequence.id}" />
|
||||||
|
<input type="text" id="seq-name" class="form-input name-input" placeholder="Display Name" value="${sequence.name}" />
|
||||||
|
<label style="display:flex; align-items:center; gap:4px; font-size: 0.9rem; cursor:pointer; white-space: nowrap;">
|
||||||
|
<input type="checkbox" id="seq-favorite" ${sequence.favorite ? 'checked' : ''} /> Favorite
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps List -->
|
||||||
|
<div id="steps-list" style="min-height: 150px; margin-bottom: 16px;">
|
||||||
|
<!-- Populated dynamically -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Step Controls -->
|
||||||
|
<div style="background: var(--surface-2); border: 1px solid var(--border); padding: 12px; border-radius: var(--radius-sm);">
|
||||||
|
<div style="margin-bottom: 8px; font-weight: bold; font-size: 0.9rem;">Add Step</div>
|
||||||
|
|
||||||
|
<div class="step-input-row">
|
||||||
|
<select id="editor-spot-select" class="form-input full-w-mobile">
|
||||||
|
<option value="">-- Select Spot --</option>
|
||||||
|
<!-- Populated dynamically -->
|
||||||
|
</select>
|
||||||
|
<input type="number" id="editor-spot-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Optional delay" />
|
||||||
|
<button id="btn-add-spot" class="btn btn-primary">+ Spot</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mobile-separator"></div>
|
||||||
|
|
||||||
|
<div class="step-input-row four-cols">
|
||||||
|
<input type="number" id="editor-x" class="form-input" placeholder="X" />
|
||||||
|
<input type="number" id="editor-y" class="form-input" placeholder="Y" />
|
||||||
|
<input type="number" id="editor-xy-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Optional delay" />
|
||||||
|
<button id="btn-add-xy" class="btn btn-primary">+ XY</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="text-input-divider" class="mobile-separator" style="display:none;"></div>
|
||||||
|
|
||||||
|
<div id="text-input-row" class="step-input-row" style="display:none;">
|
||||||
|
<input type="text" id="editor-text" class="form-input full-w-mobile" placeholder="Text (e.g. HELLO)" />
|
||||||
|
<input type="number" id="editor-text-delay" class="form-input" placeholder="Delay (ms)" step="100" title="Delay between letters" />
|
||||||
|
<button id="btn-add-text" class="btn btn-primary">+ Text</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JSON Export -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||||
|
<span class="card-title">JSON Export</span>
|
||||||
|
<button id="btn-save-browser" class="btn btn-sm btn-primary">SAVE TO BROWSER</button>
|
||||||
|
</div>
|
||||||
|
<textarea id="json-preview" class="form-input" style="width: 100%; height: 200px; font-family: monospace; font-size: 0.85rem; resize: vertical;" readonly></textarea>
|
||||||
|
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 8px;">
|
||||||
|
Copy this JSON array into <code>web/assets/backgrounds/${currentBg}/sequences.json</code> to persist it.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Control Panel ──────────────────────────────── -->
|
||||||
|
<div style="display:flex; flex-direction:column; gap: 12px;">
|
||||||
|
|
||||||
|
<!-- Homing Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||||
|
<span class="card-title">System Status</span>
|
||||||
|
<div style="display:flex; gap: 12px;">
|
||||||
|
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">X (mm)</div><div id="pos-x" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
|
||||||
|
<div style="text-align:right"><div style="font-size:0.6rem; color:var(--text-muted); text-transform:uppercase;">Y (mm)</div><div id="pos-y" style="font-weight:bold; font-size:1rem; color:var(--accent-green);">0.0</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="homing-banner" class="homing-banner ${isHomed ? 'homed' : 'not-homed'}">
|
||||||
|
${isHomed ? '✓ HOMED' : '⚠ NOT HOMED! Movement Locked.'}
|
||||||
|
</div>
|
||||||
|
<button id="btn-force-home" class="btn btn-danger btn-full" style="padding: 10px; font-weight:bold;">
|
||||||
|
${isHomed ? 'FORCE HOME ALL' : 'HOME ALL MOTORS'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Motion Settings</span></div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-bottom: 12px;">
|
||||||
|
<label class="form-label">Background Context</label>
|
||||||
|
<select id="bg-select" class="form-input" style="width:100%;">
|
||||||
|
<option value="default">Default Spirit Board (Landscape)</option>
|
||||||
|
<option value="portrait-right">Right Side Test (Portrait)</option>
|
||||||
|
</select>
|
||||||
|
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 4px;">Provides available spots for the editor.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: var(--surface-2); padding: 10px; border-radius: var(--radius-sm);">
|
||||||
|
<div class="setting-row">
|
||||||
|
<label class="form-label" style="margin:0;">Speed</label>
|
||||||
|
<span id="speed-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.speed}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="slider-speed" min="100" max="2000" step="100" value="${Settings.speed}" style="width:100%; margin-bottom: 8px;">
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<label class="form-label" style="margin:0;">Accel</label>
|
||||||
|
<span id="accel-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.accel}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="slider-accel" min="50" max="1000" step="50" value="${Settings.accel}" style="width:100%; margin-bottom: 8px;">
|
||||||
|
|
||||||
|
<div class="setting-row">
|
||||||
|
<label class="form-label" style="margin:0;">Global Delay (ms)</label>
|
||||||
|
<span id="delay-val" style="font-size: 0.8rem; color: var(--text-muted);">${Settings.delay}</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" id="slider-delay" min="500" max="5000" step="100" value="${Settings.delay}" style="width:100%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Saved Sequences Card -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span class="card-title">Saved Sequences</span></div>
|
||||||
|
<div id="saved-seq-list" style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<!-- Populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePositionReadout() {
|
||||||
|
const t1 = IK.stepsToRad(steps1);
|
||||||
|
const t2 = IK.stepsToRad(steps2);
|
||||||
|
const { endX, endY } = IK.forward(t1, t2);
|
||||||
|
const px = document.getElementById('pos-x');
|
||||||
|
const py = document.getElementById('pos-y');
|
||||||
|
if (px) px.textContent = isFinite(endX) ? endX.toFixed(1) : '---';
|
||||||
|
if (py) py.textContent = isFinite(endY) ? endY.toFixed(1) : '---';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHomingUI() {
|
||||||
|
const banner = document.getElementById('homing-banner');
|
||||||
|
const btn = document.getElementById('btn-force-home');
|
||||||
|
if (!banner || !btn) return;
|
||||||
|
|
||||||
|
if (isHomed) {
|
||||||
|
banner.className = 'homing-banner homed';
|
||||||
|
banner.textContent = '✓ HOMED';
|
||||||
|
btn.textContent = 'FORCE HOME ALL';
|
||||||
|
} else {
|
||||||
|
banner.className = 'homing-banner not-homed';
|
||||||
|
banner.textContent = '⚠ NOT HOMED! Movement Locked.';
|
||||||
|
btn.textContent = 'HOME ALL MOTORS';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStepsList() {
|
||||||
|
const container = document.getElementById('steps-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (sequence.steps.length === 0) {
|
||||||
|
container.innerHTML = `<div style="text-align: center; color: var(--text-muted); padding: 20px;">No steps added yet.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = sequence.steps.map((step, idx) => {
|
||||||
|
let label = '';
|
||||||
|
let details = '';
|
||||||
|
if (step.type === 'spot') {
|
||||||
|
label = `Spot: <b>${step.id}</b>`;
|
||||||
|
} else {
|
||||||
|
label = `XY: <b>(${step.x}, ${step.y})</b>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step.delay !== undefined) {
|
||||||
|
details = `<span style="font-size: 0.8rem; color: var(--accent-amber); margin-left: 8px;">Delay: ${step.delay}ms</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="step-box">
|
||||||
|
<div>
|
||||||
|
<span style="font-family: monospace; font-size: 0.9rem; color: var(--text-muted); margin-right: 8px;">#${idx+1}</span>
|
||||||
|
${label} ${details}
|
||||||
|
</div>
|
||||||
|
<div class="step-controls">
|
||||||
|
<button class="btn-icon btn-move-up" data-idx="${idx}" title="Move Up">↑</button>
|
||||||
|
<button class="btn-icon btn-move-down" data-idx="${idx}" title="Move Down">↓</button>
|
||||||
|
<button class="btn-icon btn-delete-step" data-idx="${idx}" title="Delete" style="color: var(--accent-red);">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Attach listeners
|
||||||
|
container.querySelectorAll('.btn-move-up').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
if (idx > 0) {
|
||||||
|
[sequence.steps[idx - 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx - 1]];
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-move-down').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
if (idx < sequence.steps.length - 1) {
|
||||||
|
[sequence.steps[idx + 1], sequence.steps[idx]] = [sequence.steps[idx], sequence.steps[idx + 1]];
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete-step').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
sequence.steps.splice(idx, 1);
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpots() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`web/assets/backgrounds/${currentBg}/spots.json?v=${Date.now()}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
spots = data;
|
||||||
|
hasAlphabet = false;
|
||||||
|
} else {
|
||||||
|
spots = data.spots || [];
|
||||||
|
if (data.bounds) bounds = data.bounds;
|
||||||
|
hasAlphabet = !!data.hasAlphabet;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('text-input-row').style.display = hasAlphabet ? 'grid' : 'none';
|
||||||
|
document.getElementById('text-input-divider').style.display = hasAlphabet ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Populate spot dropdown
|
||||||
|
const select = document.getElementById('editor-spot-select');
|
||||||
|
if (select) {
|
||||||
|
select.innerHTML = '<option value="">-- Select Spot --</option>' + spots.map(s =>
|
||||||
|
`<option value="${s.id}">${s.label || s.id} [${s.x}, ${s.y}]</option>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateJSONPreview();
|
||||||
|
await loadBgSequences();
|
||||||
|
} catch(e) {
|
||||||
|
UI.log('Failed to load spots.json', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBgSequences() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`web/assets/backgrounds/${currentBg}/sequences.json?v=${Date.now()}`);
|
||||||
|
if (res.ok) {
|
||||||
|
bgSequences = await res.json();
|
||||||
|
} else {
|
||||||
|
bgSequences = [];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
bgSequences = [];
|
||||||
|
}
|
||||||
|
renderSavedSequences();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSavedSequences() {
|
||||||
|
const container = document.getElementById('saved-seq-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
|
||||||
|
if (bgSequences.length > 0) {
|
||||||
|
html += `<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; margin-top:4px;">From sequences.json</div>`;
|
||||||
|
html += bgSequences.map((s, idx) => `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:8px; border-radius:var(--radius-sm); border:1px solid var(--border);">
|
||||||
|
<span style="font-size:0.9rem;">${s.name}</span>
|
||||||
|
<button class="btn btn-sm btn-ghost btn-load-seq" data-source="bg" data-idx="${idx}">Load</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (savedBrowserSequences.length > 0) {
|
||||||
|
html += `<div style="font-size:0.75rem; color:var(--text-muted); text-transform:uppercase; margin-top:8px;">From Browser Storage</div>`;
|
||||||
|
html += savedBrowserSequences.map((s, idx) => `
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; background:var(--surface-2); padding:8px; border-radius:var(--radius-sm); border:1px solid var(--border);">
|
||||||
|
<span style="font-size:0.9rem;">${s.name}</span>
|
||||||
|
<div style="display:flex; gap:4px;">
|
||||||
|
<button class="btn btn-sm btn-ghost btn-load-seq" data-source="browser" data-idx="${idx}">Load</button>
|
||||||
|
<button class="btn btn-sm btn-ghost btn-delete-seq" data-idx="${idx}" style="color:var(--accent-red);" title="Delete from Browser Storage">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!html) {
|
||||||
|
html = `<div style="text-align: center; color: var(--text-muted); font-size: 0.85rem; padding: 12px;">No saved sequences</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-load-seq').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const source = btn.dataset.source;
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
const srcObj = source === 'bg' ? bgSequences[idx] : savedBrowserSequences[idx];
|
||||||
|
if (srcObj) {
|
||||||
|
sequence = JSON.parse(JSON.stringify(srcObj));
|
||||||
|
document.getElementById('seq-id').value = sequence.id;
|
||||||
|
document.getElementById('seq-name').value = sequence.name;
|
||||||
|
const favCheckbox = document.getElementById('seq-favorite');
|
||||||
|
if (favCheckbox) favCheckbox.checked = !!sequence.favorite;
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
UI.log(`Loaded sequence '${sequence.name}'`, 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.querySelectorAll('.btn-delete-seq').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
if (confirm('Delete this sequence from browser storage?')) {
|
||||||
|
savedBrowserSequences.splice(idx, 1);
|
||||||
|
localStorage.setItem('wiji_saved_sequences', JSON.stringify(savedBrowserSequences));
|
||||||
|
renderSavedSequences();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSettings() {
|
||||||
|
if (!BLE.isConnected()) return;
|
||||||
|
try {
|
||||||
|
await BLE.write(`SPD:${Settings.speed}`);
|
||||||
|
await BLE.write(`ACC:${Settings.accel}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send settings', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
mount(container) {
|
||||||
|
syncStateFromStorage();
|
||||||
|
container.innerHTML = buildHTML();
|
||||||
|
|
||||||
|
updateHomingUI();
|
||||||
|
loadSpots();
|
||||||
|
renderStepsList();
|
||||||
|
|
||||||
|
// ── Input bindings for metadata ──────────────────────────────
|
||||||
|
document.getElementById('seq-id').addEventListener('input', (e) => {
|
||||||
|
sequence.id = e.target.value;
|
||||||
|
saveSequenceDraft();
|
||||||
|
});
|
||||||
|
document.getElementById('seq-name').addEventListener('input', (e) => {
|
||||||
|
sequence.name = e.target.value;
|
||||||
|
saveSequenceDraft();
|
||||||
|
});
|
||||||
|
const favCheckbox = document.getElementById('seq-favorite');
|
||||||
|
if (favCheckbox) {
|
||||||
|
favCheckbox.addEventListener('change', (e) => {
|
||||||
|
sequence.favorite = e.target.checked;
|
||||||
|
saveSequenceDraft();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Background Selection ──────────────────────────────────
|
||||||
|
const bgSelect = document.getElementById('bg-select');
|
||||||
|
if (bgSelect) {
|
||||||
|
bgSelect.value = currentBg;
|
||||||
|
bgSelect.addEventListener('change', () => {
|
||||||
|
currentBg = bgSelect.value;
|
||||||
|
localStorage.setItem('wiji_bg', currentBg);
|
||||||
|
loadSpots();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings Sliders ──────────────────────────────────────
|
||||||
|
const sSpeed = document.getElementById('slider-speed');
|
||||||
|
const sAccel = document.getElementById('slider-accel');
|
||||||
|
const sDelay = document.getElementById('slider-delay');
|
||||||
|
|
||||||
|
sSpeed.addEventListener('input', (e) => {
|
||||||
|
Settings.speed = parseInt(e.target.value);
|
||||||
|
document.getElementById('speed-val').textContent = Settings.speed;
|
||||||
|
sendSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
sAccel.addEventListener('input', (e) => {
|
||||||
|
Settings.accel = parseInt(e.target.value);
|
||||||
|
document.getElementById('accel-val').textContent = Settings.accel;
|
||||||
|
sendSettings();
|
||||||
|
});
|
||||||
|
|
||||||
|
sDelay.addEventListener('input', (e) => {
|
||||||
|
Settings.delay = parseInt(e.target.value);
|
||||||
|
document.getElementById('delay-val').textContent = Settings.delay;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Add Steps Logic ───────────────────────────────────────
|
||||||
|
document.getElementById('btn-add-spot').addEventListener('click', () => {
|
||||||
|
const select = document.getElementById('editor-spot-select');
|
||||||
|
const delayInput = document.getElementById('editor-spot-delay');
|
||||||
|
const spotId = select.value;
|
||||||
|
const delay = parseInt(delayInput.value);
|
||||||
|
|
||||||
|
if (spotId) {
|
||||||
|
const step = { type: 'spot', id: spotId };
|
||||||
|
if (!isNaN(delay) && delay >= 0) step.delay = delay;
|
||||||
|
sequence.steps.push(step);
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
select.value = "";
|
||||||
|
delayInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-add-xy').addEventListener('click', () => {
|
||||||
|
const xInput = document.getElementById('editor-x');
|
||||||
|
const yInput = document.getElementById('editor-y');
|
||||||
|
const delayInput = document.getElementById('editor-xy-delay');
|
||||||
|
|
||||||
|
const x = parseFloat(xInput.value);
|
||||||
|
const y = parseFloat(yInput.value);
|
||||||
|
const delay = parseInt(delayInput.value);
|
||||||
|
|
||||||
|
if (!isNaN(x) && !isNaN(y)) {
|
||||||
|
const step = { type: 'xy', x, y };
|
||||||
|
if (!isNaN(delay) && delay >= 0) {
|
||||||
|
step.delay = delay;
|
||||||
|
}
|
||||||
|
sequence.steps.push(step);
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
xInput.value = "";
|
||||||
|
yInput.value = "";
|
||||||
|
delayInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-add-text').addEventListener('click', () => {
|
||||||
|
const textInput = document.getElementById('editor-text');
|
||||||
|
const delayInput = document.getElementById('editor-text-delay');
|
||||||
|
const text = textInput.value.trim().toUpperCase();
|
||||||
|
const delay = parseInt(delayInput.value);
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const chars = Array.from(text);
|
||||||
|
let addedCount = 0;
|
||||||
|
for (const char of chars) {
|
||||||
|
const spot = spots.find(s =>
|
||||||
|
(s.label && s.label.toUpperCase() === char) ||
|
||||||
|
(s.id && s.id.toUpperCase() === char)
|
||||||
|
);
|
||||||
|
if (spot) {
|
||||||
|
const step = { type: 'spot', id: spot.id };
|
||||||
|
if (!isNaN(delay) && delay >= 0) step.delay = delay;
|
||||||
|
sequence.steps.push(step);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (addedCount > 0) {
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
UI.log(`Added ${addedCount} letter spots`, 'success');
|
||||||
|
} else {
|
||||||
|
UI.log('No spots found for entered text', 'warn');
|
||||||
|
}
|
||||||
|
textInput.value = "";
|
||||||
|
delayInput.value = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-clear-seq').addEventListener('click', () => {
|
||||||
|
if (confirm('Are you sure you want to clear this sequence?')) {
|
||||||
|
sequence.steps = [];
|
||||||
|
saveSequenceDraft();
|
||||||
|
renderStepsList();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-save-browser').addEventListener('click', () => {
|
||||||
|
saveToBrowser();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sequence Execution ────────────────────────────────────
|
||||||
|
const btnPlay = document.getElementById('btn-play-editor');
|
||||||
|
const btnStop = document.getElementById('btn-stop-editor');
|
||||||
|
|
||||||
|
btnPlay.addEventListener('click', async () => {
|
||||||
|
if (!isHomed) {
|
||||||
|
UI.log('Cannot move: System is not homed!', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sequence.steps.length === 0) return;
|
||||||
|
|
||||||
|
btnPlay.style.display = 'none';
|
||||||
|
btnStop.style.display = 'inline-block';
|
||||||
|
|
||||||
|
await sendSettings();
|
||||||
|
await SequenceRunner.run(sequence, spots, steps1, steps2, (s1, s2) => {
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
updatePositionReadout();
|
||||||
|
});
|
||||||
|
|
||||||
|
btnPlay.style.display = 'inline-block';
|
||||||
|
btnStop.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
btnStop.addEventListener('click', () => {
|
||||||
|
SequenceRunner.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Homing Logic ──────────────────────────────────────────
|
||||||
|
document.getElementById('btn-force-home').addEventListener('click', async () => {
|
||||||
|
isHomed = true;
|
||||||
|
localStorage.setItem('wiji_homed', 'true');
|
||||||
|
updateHomingUI();
|
||||||
|
|
||||||
|
steps1 = IK.ARM.HOME_STEPS.m1;
|
||||||
|
steps2 = IK.ARM.HOME_STEPS.m2;
|
||||||
|
saveSteps();
|
||||||
|
updatePositionReadout();
|
||||||
|
|
||||||
|
UI.log('Homing all motors...', 'info');
|
||||||
|
if (BLE.isConnected()) {
|
||||||
|
try {
|
||||||
|
await BLE.write('HOMEALL');
|
||||||
|
} catch (e) {
|
||||||
|
UI.log(e.message, 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
UI.log('[sim] HOMEALL', 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Sync Steps from BLE ───────────────────────────────────
|
||||||
|
const onBLEStatus = (e) => {
|
||||||
|
const msg = e.detail;
|
||||||
|
if (msg.startsWith('P:')) {
|
||||||
|
const [s1, s2] = msg.slice(2).split(',').map(Number);
|
||||||
|
steps1 = s1; steps2 = s2;
|
||||||
|
saveSteps();
|
||||||
|
updatePositionReadout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('ble:status', onBLEStatus);
|
||||||
|
|
||||||
|
if (BLE.isConnected()) {
|
||||||
|
BLE.write('POS').catch(() => {});
|
||||||
|
sendSettings();
|
||||||
|
} else {
|
||||||
|
updatePositionReadout();
|
||||||
|
}
|
||||||
|
|
||||||
|
eventCleanup.push(() => document.removeEventListener('ble:status', onBLEStatus));
|
||||||
|
},
|
||||||
|
|
||||||
|
unmount() {
|
||||||
|
SequenceRunner.stop();
|
||||||
|
eventCleanup.forEach(fn => fn());
|
||||||
|
eventCleanup = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -372,7 +372,9 @@ function buildHTML() {
|
|||||||
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
|
.quick-steps button { flex:1; min-width:30px; padding:4px 2px; font-size:0.68rem;
|
||||||
border-radius:var(--radius-xs); }
|
border-radius:var(--radius-xs); }
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.jog-compact { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="section-page fade-in">
|
<div class="section-page fade-in">
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import BLE from './ble.js';
|
||||||
|
import UI from './ui.js';
|
||||||
|
import IK from './kinematics.js';
|
||||||
|
import Settings from './settings.js';
|
||||||
|
|
||||||
|
let isRunning = false;
|
||||||
|
|
||||||
|
function delayMs(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
get isRunning() { return isRunning; },
|
||||||
|
stop() { isRunning = false; },
|
||||||
|
|
||||||
|
async run(sequence, spotsData, currentSteps1, currentSteps2, onStepComplete) {
|
||||||
|
if (isRunning) {
|
||||||
|
UI.log('A sequence is already running.', 'warn');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = true;
|
||||||
|
UI.log(`Running sequence: ${sequence.name}`, 'info');
|
||||||
|
|
||||||
|
let steps1 = currentSteps1;
|
||||||
|
let steps2 = currentSteps2;
|
||||||
|
|
||||||
|
for (let i = 0; i < sequence.steps.length; i++) {
|
||||||
|
if (!isRunning) {
|
||||||
|
UI.log('Sequence stopped.', 'info');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = sequence.steps[i];
|
||||||
|
let targetX, targetY, label;
|
||||||
|
|
||||||
|
if (step.type === 'spot') {
|
||||||
|
const spot = spotsData.find(s => s.id === step.id);
|
||||||
|
if (!spot) {
|
||||||
|
UI.log(`Sequence error: Spot '${step.id}' not found. Skipping.`, 'error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetX = spot.x;
|
||||||
|
targetY = spot.y;
|
||||||
|
label = spot.label || spot.id;
|
||||||
|
} else if (step.type === 'xy') {
|
||||||
|
targetX = step.x;
|
||||||
|
targetY = step.y;
|
||||||
|
label = `(${targetX}, ${targetY})`;
|
||||||
|
} else {
|
||||||
|
UI.log(`Unknown step type: ${step.type}`, 'error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsCheck = IK.checkWorkspace(targetX, targetY);
|
||||||
|
if (!wsCheck.ok) {
|
||||||
|
UI.log(`⛔ ${wsCheck.reason} [${label}]`, 'error');
|
||||||
|
continue; // or break? let's continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = IK.solve(targetX, targetY);
|
||||||
|
if (!res.reachable || IK.armsCrossed(res.theta1, res.theta2)) {
|
||||||
|
UI.log(`Target ${label} is unreachable or crosses arms.`, 'error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newSteps1 = IK.radToSteps(res.theta1);
|
||||||
|
const newSteps2 = IK.radToSteps(res.theta2);
|
||||||
|
const delta1 = newSteps1 - steps1;
|
||||||
|
const delta2 = newSteps2 - steps2;
|
||||||
|
|
||||||
|
steps1 = newSteps1;
|
||||||
|
steps2 = newSteps2;
|
||||||
|
|
||||||
|
if (onStepComplete) {
|
||||||
|
onStepComplete(steps1, steps2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd1 = `S1${delta1 >= 0 ? '+' : ''}${delta1}`;
|
||||||
|
const cmd2 = `S2${delta2 >= 0 ? '+' : ''}${delta2}`;
|
||||||
|
|
||||||
|
UI.log(`Seq step: ${label}`, 'info');
|
||||||
|
|
||||||
|
if (BLE.isConnected()) {
|
||||||
|
try {
|
||||||
|
await BLE.write(cmd1);
|
||||||
|
await BLE.write(cmd2);
|
||||||
|
} catch (e) {
|
||||||
|
UI.log(`BLE error: ${e.message}`, 'error');
|
||||||
|
isRunning = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`[sim] ${cmd1} ${cmd2}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for delay
|
||||||
|
const waitTime = typeof step.delay === 'number' ? step.delay : Settings.delay;
|
||||||
|
await delayMs(waitTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning = false;
|
||||||
|
UI.log(`Sequence completed.`, 'success');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -58,6 +58,14 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.topbar { padding: 0 10px; gap: 8px; }
|
||||||
|
.topbar-brand { gap: 6px; font-size: 0.9rem; }
|
||||||
|
.topbar-actions { gap: 6px; }
|
||||||
|
#ble-label { display: none; }
|
||||||
|
.topbar-brand .brand-icon { width: 24px; height: 24px; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Hamburger button ────────────────────────────────────────── */
|
/* ── Hamburger button ────────────────────────────────────────── */
|
||||||
.hamburger {
|
.hamburger {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -447,6 +455,8 @@
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.form-input {
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
|
|||||||
Reference in New Issue
Block a user