Compare commits

..

2 commits

Author SHA1 Message Date
273d947c0a name change 2026-08-11 15:18:25 -05:00
8ca774f821 compact drawing list 2026-08-11 15:13:48 -05:00
5 changed files with 113 additions and 40 deletions

View file

@ -133,6 +133,12 @@ createApp({
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const drawingColors = [
'#65b7cf', '#27825c', '#45c39b', '#a8c85d',
'#efb643', '#e0a34a', '#d97a3a', '#bd4545',
'#d96073', '#b07ad6', '#7d67c9', '#4f6fae',
'#9fb0c4', '#746c60', '#2c2924', '#c8992f',
];
const now = ref(Date.now());
let chartApi = null;
let socket = null;
@ -647,6 +653,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned };
},
}).mount('#app');

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>/ESsence</title>
<title>/ESsent</title>
<link rel="stylesheet" href="/static/style.css">
<!-- Font Awesome Free 7.3.1, from unpkg like the other two dependencies.
Pinned deliberately: an unpinned icon set is a silent redesign on someone
@ -15,7 +15,7 @@
<body>
<div id="app">
<header>
<h1><strong>/ES</strong>sence</h1>
<h1><strong>/ES</strong>sent</h1>
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
</header>
<main>
@ -133,35 +133,75 @@
@click.stop @change="toggleLineSelection(item.id)">
<span v-else class="line-select drawing-icon"><i class="fa-solid fa-note-sticky"></i></span>
<input v-if="item.kind !== 'comment'" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<div class="drawing-content">
<div class="drawing-primary">
<input v-if="item.kind !== 'comment'" :value="item.line.label" aria-label="Drawing name"
@click.stop @change="renameLine(item.line, $event.target.value)">
<input v-else :value="item.comment.note" aria-label="Comment text"
@click.stop @change="renameLine(item.comment, $event.target.value)">
<label v-if="item.kind === 'comment'" class="drawing-state" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar' : 'Floating on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
<i :class="item.comment.pinned ? 'fa-solid fa-thumbtack' : 'fa-solid fa-up-down-left-right'"></i>
</label>
<label v-else class="drawing-state" :class="{off: !item.line.armed}" @click.stop
:title="item.line.armed ? 'Armed — click to disarm' : 'Tripped — click to re-arm'">
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
<i :class="item.line.armed ? 'fa-solid fa-bell' : 'fa-solid fa-bell-slash'"></i>
</label>
<button class="drawing-delete" @click.stop="deleteDrawing(item)" aria-label="Delete drawing" title="Delete">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<span v-if="item.kind === 'comment'">#{{ item.number }} · comment · {{ item.comment.pinned ? 'pinned' : 'floating' }}</span>
<span v-else-if="item.kind === 'level'">#{{ item.number }} · level {{ item.line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side }}</span>
<div class="drawing-secondary">
<span v-if="item.kind === 'comment'">#{{ item.number }} · NOTE · {{ item.comment.pinned ? 'PIN' : 'FLOAT' }}</span>
<span v-else-if="item.kind === 'level'">#{{ item.number }} · L {{ item.line.anchor_p.toFixed(2) }}</span>
<span v-else>#{{ item.number }} · {{ item.line.tf }} · {{ item.line.side === 'support' ? '↑' : '↓' }}</span>
<label v-if="item.kind === 'comment'" class="armed-toggle" @click.stop
:title="item.comment.pinned ? 'Pinned to its bar — scrolls with the chart' : 'Floating — always on screen'">
<input type="checkbox" :checked="!item.comment.pinned" @change="togglePinned(item.comment)">
{{ item.comment.pinned ? 'pinned' : 'float' }}
</label>
<label v-else class="armed-toggle" :class="{off: !item.line.armed}" @click.stop
:title="item.line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
<input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
{{ item.line.armed ? 'armed' : 'tripped' }}
</label>
<button @click.stop="deleteDrawing(item)" aria-label="Delete drawing">Delete</button>
<div class="line-style-controls" @click.stop v-if="item.kind !== 'comment'">
<input type="color" :value="item.line.color || '#65b7cf'" aria-label="Drawing color" @change="updateLineStyle(item.line, { color: $event.target.value })">
<select :value="item.line.line_width || 2" aria-label="Drawing width" @change="updateLineStyle(item.line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
</div>
<div class="line-style-controls" @click.stop v-else>
<input type="color" :value="item.comment.color || '#c8992f'" aria-label="Comment colour" @change="updateLineStyle(item.comment, { color: $event.target.value })">
<button class="collapse-toggle" @click.stop="toggleComment(item.comment)">{{ item.comment.collapsed ? 'expand' : 'collapse' }}</button>
<div class="drawing-controls" @click.stop v-if="item.kind !== 'comment'">
<details class="color-picker">
<summary aria-label="Choose drawing color" title="Choose color"
:style="{ backgroundColor: item.line.color || '#65b7cf' }"></summary>
<div class="color-popover">
<button v-for="color in drawingColors" :key="color" type="button"
:class="{selected: color === item.line.color}" :style="{backgroundColor: color}"
:aria-label="`Use color ${color}`"
@click.stop="updateLineStyle(item.line, {color}); $event.currentTarget.closest('details').open = false"></button>
<label class="custom-color" title="Custom color">
<i class="fa-solid fa-palette"></i>
<input type="color" :value="item.line.color || '#65b7cf'" aria-label="Custom drawing color"
@change="updateLineStyle(item.line, {color: $event.target.value})">
</label>
</div>
</details>
<select :value="item.line.line_width || 2" aria-label="Drawing width"
@change="updateLineStyle(item.line, {line_width: Number($event.target.value)})">
<option v-for="width in [1,2,3,4]" :value="width">{{ width }}</option>
</select>
</div>
<div class="drawing-controls" @click.stop v-else>
<details class="color-picker">
<summary aria-label="Choose comment color" title="Choose color"
:style="{ backgroundColor: item.comment.color || '#c8992f' }"></summary>
<div class="color-popover">
<button v-for="color in drawingColors" :key="color" type="button"
:class="{selected: color === item.comment.color}" :style="{backgroundColor: color}"
:aria-label="`Use color ${color}`"
@click.stop="updateLineStyle(item.comment, {color}); $event.currentTarget.closest('details').open = false"></button>
<label class="custom-color" title="Custom color">
<i class="fa-solid fa-palette"></i>
<input type="color" :value="item.comment.color || '#c8992f'" aria-label="Custom comment color"
@change="updateLineStyle(item.comment, {color: $event.target.value})">
</label>
</div>
</details>
<button class="collapse-toggle" @click.stop="toggleComment(item.comment)"
:title="item.comment.collapsed ? 'Expand comment' : 'Collapse comment'">
<i :class="item.comment.collapsed ? 'fa-solid fa-expand' : 'fa-solid fa-compress'"></i>
</button>
</div>
</div>
</div>
</div>
</details>

View file

@ -18,7 +18,11 @@ button { border:1px solid var(--line); background:transparent; color:var(--muted
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span:not(.drawing-icon) { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row>.drawing-icon { grid-column:1; align-self:center; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:91px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:nowrap; gap:7px; padding-left:20px; }.periods label { color:var(--muted); gap:4px; }.periods input { width:12px; height:12px; margin:0; flex:none; }.layer-inline { display:flex; align-items:center; gap:12px; }.layer-inline .disabled { gap:2px; }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
@ -48,8 +52,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }
.armed-toggle { display:flex; align-items:center; gap:4px; font-size:10px; color:var(--accent); cursor:pointer; }
.armed-toggle.off { color:var(--muted); text-decoration:line-through; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:40px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
@ -72,8 +75,6 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.drawing-filters select, .drawing-filters input { font:inherit; font-size:10px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); min-width:0; }
.drawing-icon { display:flex; align-items:center; justify-content:center; color:var(--muted); font-size:10px; }
.collapse-toggle { font:inherit; font-size:9px; padding:2px 6px; border:1px solid var(--line);
border-radius:4px; background:transparent; color:var(--muted); cursor:pointer; }
.tool-body textarea { width:100%; min-width:0; font:inherit; font-size:11px; padding:4px 6px; resize:vertical;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.side-auto { align-self:end; padding-bottom:6px; font-size:10px; color:var(--muted); }.side-auto b { color:var(--fg); font-weight:600; }

View file

@ -35,6 +35,20 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
await page.fill('input[aria-label="Filter drawings by text"]', label);
const input = page.locator('input[aria-label="Drawing name"]');
assert.equal(await input.count(), 1, 'the unique drawing filter did not isolate the test line');
const row = page.locator('.trendline-row');
const rowBox = await row.boundingBox();
assert.ok(rowBox.height <= 48, `drawing row is still ${rowBox.height}px tall`);
assert.equal(await row.locator('.drawing-state .fa-bell').count(), 1,
'the armed state is not represented by a bell');
await row.locator('.color-picker>summary').click();
assert.equal(await row.locator('.color-popover>button').count(), 16,
'the preset palette does not contain 16 colors');
assert.equal(await row.locator('.custom-color input[type="color"]').count(), 1,
'the custom color choice is missing');
await row.locator('button[aria-label="Use color #bd4545"]').click();
await page.waitForFunction(([id, color]) =>
window.__chart.levels.find(level => level.id === id)?.color === color,
[created, '#bd4545']);
await input.focus();
await input.press('End');
await input.press('Backspace');

View file

@ -117,6 +117,8 @@ test('the snapped extreme decides the side, overriding the dropdown', { timeout:
test('dragging an anchor uses the same bar-extreme snapping as placement',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const beforeNumber = await page.evaluate(() => Math.max(0, ...window.__chart.levels
.filter(level => level.kind === 'manual').map(level => level.number)));
const box = await chartBox(page);
await armTool(page, 'Trendline');
const first = at(box, 0.30, 0.55);
@ -124,8 +126,12 @@ test('dragging an anchor uses the same bar-extreme snapping as placement',
await page.mouse.click(first.x, first.y);
await page.waitForTimeout(300);
await page.mouse.click(second.x, second.y);
await page.waitForFunction(() =>
document.querySelectorAll('.chart-anchor:not([hidden])').length === 2);
await page.waitForFunction(number => {
const c = window.__chart;
const selected = c.levels.find(level => level.id === c.selectedLineId);
return selected?.number > number
&& document.querySelectorAll('.chart-anchor:not([hidden])').length === 2;
}, beforeNumber);
const target = await page.evaluate(() => {
const c = window.__chart;
@ -188,7 +194,7 @@ test('future whitespace accepts endpoint and whole-line movement without enterin
return {
id: line.id,
x: plot.left + c.coordinateAtIndex(index),
y: plot.top + c.candles.priceToCoordinate(c.linePriceAtIndex(line, index)),
y: plot.top + plot.height * 0.45,
};
});
const endHandle = await page.locator(
@ -280,9 +286,11 @@ test('the line context menu duplicates by ten bars and deletes through the norma
{ button: 'right' });
await page.locator('.chart-context-menu [data-action="duplicate"]').click();
await page.waitForFunction(number => {
const lines = window.__chart.levels.filter(level =>
const c = window.__chart;
const lines = c.levels.filter(level =>
level.kind === 'manual' && level.slope !== 0);
return lines.some(level => level.number > number);
const duplicate = lines.find(level => level.number > number);
return duplicate && c.selectedLineId === duplicate.id;
}, original.number);
const duplicated = await page.evaluate(originalLine => {
@ -297,6 +305,8 @@ test('the line context menu duplicates by ten bars and deletes through the norma
line,
anchorShift: nearestIndex(line.anchor_t) - nearestIndex(original.anchor_t),
endShift: nearestIndex(line.last_t) - nearestIndex(original.last_t),
originalTitle: c.levelSeries.get(original.id).series.options().title,
duplicateTitle: c.levelSeries.get(line.id).series.options().title,
};
}, original);
assert.equal(duplicated.anchorShift, 10);
@ -304,6 +314,8 @@ test('the line context menu duplicates by ten bars and deletes through the norma
const expectedName = `${original.side === 'support' ? 'up' : 'down'}${original.tf}`;
assert.equal(original.label, expectedName);
assert.equal(duplicated.line.label, `${expectedName} 2`);
assert.equal(duplicated.originalTitle, `#${original.number}`);
assert.equal(duplicated.duplicateTitle, duplicated.line.label);
assert.equal(duplicated.line.color, original.color);
assert.equal(duplicated.line.line_width, original.line_width);