Add chart comments and turn Lines & levels into Drawings

A comment is a ManualLine with kind="comment", so it inherits persistence, the
shared drawing-number sequence, the list, filtering and deletion rather than
needing a parallel set of endpoints. The rule that must never bend is that
ManualLineStore.levels() excludes them: a comment reaching the level list would
join a confluence cluster and push a phone notification about a piece of text.
It is created with armed=False, and PATCH returns to_dict() rather than
to_level() for a comment, so nothing is ever handed a level-shaped comment.
Three tests cover the exclusion, the shared numbering and the kind derived for
drawings saved before comments existed.

Pinned comments carry anchor_t and anchor_p and travel with the chart; floating
ones carry x and y as fractions of the pane, hold their place through any zoom
and can be dragged. They render as DOM rather than canvas because they hold
arbitrary text, collapse to a numbered dot, and a floating one must ignore the
time scale entirely. A pinned comment scrolled out of view parks on the edge it
left, pointing back toward itself, so it never simply disappears.

Lines & levels becomes Drawings, filtered by type and by text. The text match
covers the label, the kind and the #number, so "comment", "cpi" and "7" all
narrow the list, and Delete acts on whatever the filter shows — which is what
makes deleting by type or by string one button.

Verified in a browser: placing a comment renders it at the click, clicking it
collapses it, scrolling away parks it on the edge, a floating one holds its
pixel position through a 300-bar scroll, and the filters cut 3 rows to 1 by type,
1 by text and 0 for a miss, with no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 15:58:21 -05:00
parent 078bc8f42c
commit 9ab68f6cff
6 changed files with 245 additions and 20 deletions

View file

@ -217,7 +217,9 @@ def patch_line(request: Request, line_id: str, payload: LinePatch):
except KeyError as exc: except KeyError as exc:
raise HTTPException(404, "Line not found") from exc raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels() request.app.state.runtime.rebuild_levels()
return line.to_level().to_dict() # A comment has no level form — returning one would hand the caller a shape
# that looks like something the confluence engine tracks.
return line.to_dict() if line.is_comment else line.to_level().to_dict()
@router.delete("/lines/{line_id}", status_code=204) @router.delete("/lines/{line_id}", status_code=204)

View file

@ -1444,3 +1444,33 @@ nothing selected. Five changes, no functionality removed:
Measured with nothing armed: 1110px of content down to 900px, which is inside Measured with nothing armed: 1110px of content down to 900px, which is inside
the viewport rather than past it. `.sidebar-section:first-of-type` carries the the viewport rather than past it. `.sidebar-section:first-of-type` carries the
zeroed top margin so reordering cannot reintroduce a gap at the top. zeroed top margin so reordering cannot reintroduce a gap at the top.
### 2026-08-10 (evening) — chart comments, and Drawings
**Comments are drawings, not levels.** A comment is stored as a `ManualLine`
with `kind="comment"`, so it inherits persistence, the shared drawing-number
sequence, the sidebar list, filtering and deletion without a parallel set of
endpoints. The one rule that must never bend: `ManualLineStore.levels()` filters
comments out. A comment reaching the level list would join a confluence cluster
and push a phone notification about a piece of text. It is also created with
`armed=False`, and `PATCH /lines/{id}` returns `to_dict()` rather than
`to_level()` for one, so no caller is ever handed a level-shaped comment.
`kind` is derived when absent — zero slope was always a typed level, anything
else a drawn trendline — so drawings saved before comments existed keep working.
**Pinned or floating.** Pinned comments carry `anchor_t`/`anchor_p` and move with
the chart; floating ones carry `x`/`y` as fractions of the pane, hold their place
through any zoom, and can be dragged. Comments render as DOM rather than canvas:
they hold arbitrary text, collapse to a numbered dot, and a floating one has to
ignore the time scale entirely. A pinned comment scrolled out of view parks on
the edge it left, pointing back the way it went, so it is never simply lost.
**"Lines & levels" becomes "Drawings"**, filtered by type and by text — the text
match covers the label, the kind and the `#number`, so `comment`, `cpi` and `7`
all narrow the list. Delete acts on what the filter shows, which is what makes
deleting by type or by string a single button.
One CSS trap worth recording: `.trendline-row span { grid-column:2 }` captured
the comment row's icon span and dragged it into the text column. Scoped to
`span:not(.drawing-icon)`.

View file

@ -84,6 +84,13 @@ createApp({
const drawColor = ref('#65b7cf'); const drawColor = ref('#65b7cf');
const drawWidth = ref(2); const drawWidth = ref(2);
const drawSide = ref('support'); const drawSide = ref('support');
const commentText = ref('');
const commentFloat = ref(false);
// Comments are not levels, so they never arrive over the levels socket and
// are fetched and refreshed explicitly.
const comments = ref([]);
const drawingFilter = ref('');
const drawingKind = ref('all');
const snap = ref(true); const snap = ref(true);
const selectedLine = ref(null); const selectedLine = ref(null);
const selectedLines = ref([]); const selectedLines = ref([]);
@ -107,6 +114,37 @@ createApp({
const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null); const hasLineSelection = computed(() => selectedLines.value.length > 0 || selectedLine.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
const drawings = computed(() => {
const lines = manualLines.value.map(line => ({
id: line.id,
number: line.number,
kind: line.slope === 0 ? 'level' : 'trendline',
label: line.label || '',
line,
}));
const notes = comments.value.map(comment => ({
id: comment.id,
number: comment.number,
kind: 'comment',
label: comment.note || '',
comment,
}));
return [...lines, ...notes].sort((a, b) => a.number - b.number);
});
const filteredDrawings = computed(() => {
const needle = drawingFilter.value.trim().toLowerCase();
return drawings.value.filter(item => {
if (drawingKind.value !== 'all' && item.kind !== drawingKind.value) return false;
if (!needle) return true;
// Matched against what is on screen — the label or comment text, the
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle);
});
});
async function refreshStatus() { async function refreshStatus() {
const response = await apiFetch('/api/status'); const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json(); if (response.ok) status.value = await response.json();
@ -210,6 +248,10 @@ createApp({
// One placement per arming, so a tool cannot keep firing on stray drags. // One placement per arming, so a tool cannot keep firing on stray drags.
armedTool.value = null; armedTool.value = null;
chartApi.armTool(null); chartApi.armTool(null);
if (result.tool === 'comment') {
await createComment(result);
return;
}
if (result.tool === 'level') { if (result.tool === 'level') {
alertPrice.value = result.price; alertPrice.value = result.price;
await addPriceAlert(); await addPriceAlert();
@ -218,6 +260,64 @@ createApp({
await createTrendline(result.start, result.end); await createTrendline(result.start, result.end);
} }
async function refreshComments() {
const response = await apiFetch('/api/drawings');
if (!response.ok) return;
const payload = await response.json();
comments.value = (payload.drawings || []).filter(item => item.kind === 'comment');
syncComments();
}
function syncComments() {
if (chartApi) chartApi.setComments(comments.value.filter(item => !item.hidden));
}
async function createComment(result) {
const text = commentText.value.trim();
if (!text) return;
const response = await apiFetch('/api/comments', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text,
pinned: !commentFloat.value,
anchor_t: result.time ?? null,
anchor_p: result.price ?? null,
x: result.x, y: result.y,
}),
});
if (!response.ok) {
console.error('Unable to save comment', response.status);
return;
}
commentText.value = '';
await refreshComments();
}
async function patchComment(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (response.ok) await refreshComments();
}
const toggleComment = comment => patchComment(comment, { collapsed: !comment.collapsed });
const moveComment = (comment, x, y) => patchComment(comment, { x, y });
const togglePinned = comment => patchComment(comment, { pinned: !comment.pinned });
async function deleteDrawing(item) {
await apiFetch(`/api/lines/${encodeURIComponent(item.id)}`, { method: 'DELETE' });
if (item.kind === 'comment') await refreshComments();
else levels.value = levels.value.filter(level => level.id !== item.id);
syncVisibleLevels();
}
async function deleteFilteredDrawings() {
const doomed = [...filteredDrawings.value];
if (!doomed.length) return;
for (const item of doomed) await deleteDrawing(item);
}
async function createTrendline(first, second) { async function createTrendline(first, second) {
const [start, end] = [first, second].sort((a, b) => a.t - b.t); const [start, end] = [first, second].sort((a, b) => a.t - b.t);
const side = start.snappedSide || end.snappedSide || drawSide.value; const side = start.snappedSide || end.snappedSide || drawSide.value;
@ -327,6 +427,9 @@ createApp({
}); });
if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; } if (!response.ok) { console.error(`Unable to update line: HTTP ${response.status}`); return; }
const saved = await response.json(); const saved = await response.json();
// A comment is not a level and must not be pushed into the level list,
// where it would be drawn as a line.
if (line.kind === 'comment') { await refreshComments(); return; }
levels.value = levels.value.map(level => level.id === line.id ? saved : level); levels.value = levels.value.map(level => level.id === line.id ? saved : level);
syncVisibleLevels(); syncVisibleLevels();
} }
@ -434,6 +537,8 @@ createApp({
chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineChangeHandler(updateLineGeometry);
chartApi.setLineEndHandler(endLineHere); chartApi.setLineEndHandler(endLineHere);
chartApi.setSnap(snap.value); chartApi.setSnap(snap.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment });
refreshComments();
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
refreshStatus(); refreshStatus();
connect(); connect();
@ -446,6 +551,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, barAge, 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 }; return { status, price, barAge, 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 };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -545,6 +545,21 @@ class ConfluenceChart {
this.clearGesture(); this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId); this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === 'comment') {
// Both placements come from one gesture: the time and price under the
// cursor for a pinned comment, and the same spot as a fraction of the
// pane for a floating one, so the tool decides which without a second
// interaction.
this.onToolComplete?.({
tool,
time: end.t,
price: ConfluenceChart.snapToTick(end.p),
x: Math.min(Math.max(end.x / this.chartEl.clientWidth, 0), 1),
y: Math.min(Math.max(end.y / this.chartEl.clientHeight, 0), 1),
});
return;
}
if (tool === 'level') { if (tool === 'level') {
// A click with no drag is a valid placement; the drag is only there to // A click with no drag is a valid placement; the drag is only there to
// let you fine-tune the price before committing. // let you fine-tune the price before committing.

View file

@ -88,27 +88,76 @@
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p> <p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div> </div>
</div> </div>
<div class="tool" :class="{armed: armedTool === 'comment'}">
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'comment'">
<label>Text<textarea v-model.trim="commentText" rows="2" placeholder="What happened here?" aria-label="Comment text"></textarea></label>
<label class="check"><input type="checkbox" v-model="commentFloat">Float (stay on screen)</label>
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
</div>
</details> </details>
<details class="sidebar-section" open> <details class="sidebar-section" open>
<summary>Lines &amp; levels ({{ manualLines.length }})</summary> <summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
<div class="trendline-actions" v-if="manualLines.length"> <div class="drawing-filters">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button> <select v-model="drawingKind" aria-label="Filter drawings by type">
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button> <option value="all">All types</option>
<option value="trendline">Trendlines</option>
<option value="level">Levels</option>
<option value="comment">Comments</option>
</select>
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div> </div>
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div> <div class="trendline-actions" v-if="drawings.length">
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)"> <button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)"> <button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)"> :title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
<span v-if="line.slope === 0">#{{ line.number }} · level {{ line.anchor_p.toFixed(2) }}</span> Delete shown ({{ filteredDrawings.length }})
<span v-else>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span> </button>
<label class="armed-toggle" :class="{off: !line.armed}" @click.stop :title="line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'"> </div>
<input type="checkbox" :checked="line.armed" @change="setArmed(line, $event.target.checked)"> <div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
{{ line.armed ? 'armed' : 'tripped' }} <div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedLines.includes(item.id)}"
@click="item.kind !== 'comment' && toggleLineSelection(item.id)">
<input v-if="item.kind !== 'comment'" class="line-select" type="checkbox"
:checked="selectedLines.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@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)">
<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>
<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>
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button> <label v-else class="armed-toggle" :class="{off: !item.line.armed}" @click.stop
<div class="line-style-controls" @click.stop> :title="item.line.armed ? 'Armed — will alert once, then disarm' : 'Tripped — re-arm to alert again'">
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })"> <input type="checkbox" :checked="item.line.armed" @change="setArmed(item.line, $event.target.checked)">
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select> {{ 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> </div>
</div> </div>
</details> </details>

View file

@ -18,7 +18,7 @@ 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; } .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; }
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; } 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; } .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 { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.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: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; }
.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; } .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; } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
@ -52,3 +52,27 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.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; } .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; }
.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; } .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:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } @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:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }
/* --- chart comments ---------------------------------------------------- */
.chart-comments { position:absolute; inset:0; pointer-events:none; z-index:4; }
.chart-comment { position:absolute; max-width:210px; padding:4px 7px; border:1px solid var(--muted);
border-left-width:3px; border-radius:4px; background:var(--chart-bg); color:var(--fg);
font-size:10px; line-height:1.35; pointer-events:auto; cursor:pointer; white-space:pre-wrap;
box-shadow:0 1px 3px rgba(0,0,0,.16); }
.chart-comment.collapsed { max-width:none; width:18px; height:18px; padding:0; display:flex;
align-items:center; justify-content:center; border-radius:50%; border-left-width:1px;
font-size:9px; white-space:nowrap; }
.chart-comment.floating { border-style:dashed; cursor:grab; }
/* Parked on the edge it scrolled off, pointing the way back to it. */
.chart-comment.off-left::before, .chart-comment.off-right::before { color:var(--muted); font-size:9px; }
.chart-comment.off-left::before { content:'◀ '; }
.chart-comment.off-right::after { content:' ▶'; color:var(--muted); font-size:9px; }
.chart-comment.off-left, .chart-comment.off-right { opacity:.72; }
/* --- drawings filter --------------------------------------------------- */
.drawing-filters { display:grid; grid-template-columns:auto 1fr; gap:6px; margin-bottom:8px; }
.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); }