diff --git a/docs/color_refactor.md b/docs/color_refactor.md
new file mode 100644
index 0000000..db120bb
--- /dev/null
+++ b/docs/color_refactor.md
@@ -0,0 +1,78 @@
+# Drawing color refactor
+
+Source discussion: [Color Coded Trendlines Tips](https://chatgpt.com/share/6a7e6fc3-5dd0-83ea-9d66-5e6f3f6d96e7).
+
+This document records the useful design work from that conversation and the
+final decision made afterwards. The picker is a grouped palette, not an
+unordered bag of colors: six hue families, four deliberately separated
+lightness variants per family, arranged as three contrasting row pairs.
+
+## Pairing
+
+The rows are kept in three high-contrast pairs:
+
+1. green / red
+2. blue / orange
+3. teal / purple
+
+The application assigns no timeframe, direction, drawing kind, or other
+semantic meaning to a pair. Persisted names are only color names (`green1`,
+`red3`, `blue2`). Users can apply any convention without fighting an encoded
+mapping or changing drawing identity later.
+
+## Palette
+
+Columns run from `1` (lightest) to `4` (darkest). The larger-than-usual
+lightness steps are intentional: adjacent variants need to remain distinct on a
+dense chart, not merely look harmonious in a design swatch.
+
+| Family | 1 | 2 | 3 | 4 |
+|---|---|---|---|---|
+| green | `green1 (#A6D8AA)` | `green2 (#4DB155)` | `green3 (#258F33)` | `green4 (#006D09)` |
+| red | `red1 (#FAADBC)` | `red2 (#F45B78)` | `red3 (#D13F62)` | `red4 (#AF2850)` |
+| blue | `blue1 (#A3CCFF)` | `blue2 (#4699FE)` | `blue3 (#1E76D8)` | `blue4 (#0054B3)` |
+| orange | `orange1 (#F8C592)` | `orange2 (#F08A24)` | `orange3 (#D66B12)` | `orange4 (#B94E08)` |
+| teal | `teal1 (#80D8D8)` | `teal2 (#00B0B1)` | `teal3 (#008E8F)` | `teal4 (#006C6E)` |
+| purple | `purple1 (#DDBCEB)` | `purple2 (#BB79D7)` | `purple3 (#9858B3)` | `purple4 (#763790)` |
+
+## Picker behavior
+
+- Render six rows of four swatches in the table order above.
+- Keep each contrasting pair adjacent.
+- Add a small visual break between the three pairs.
+- Show stable name and exact value together wherever a name appears, for
+ example `blue1 (#4699FE)` in tooltips and accessible labels.
+- Keep the native color wheel for arbitrary values and Cancel on a final row.
+- Existing persisted colors outside this palette remain valid and appear as
+ `custom (#RRGGBB)`; there is no migration or recoloring of saved drawings.
+- Each family row has an optional user annotation stored in `localStorage`.
+ Blank annotations show the family name. Annotations carry user-defined
+ meaning only and never alter color names or drawing data.
+- Hovering a drawing on the chart shows its drawing label, stable color name
+ and hex, and the optional row annotation on separate lines. Custom colors
+ omit the annotation because they do not belong to a palette row.
+
+`localStorage` is intentionally temporary, not the server persistence design.
+When cross-device preference sync is built, these annotations move into the
+user-keyed, namespaced JSON preference store described in `multi_user.md` under
+`drawing_palette`. They must not become a global file or dedicated columns per
+family; adding future palette preferences should require no database schema
+change.
+
+## Notes from the source discussion
+
+The source also considered family labels, larger standalone swatches, a detail
+card with RGB values, categorical auto-color presets, and semantic labels baked
+into color names. Those are intentionally not part of this compact sidebar
+picker. Color-family names are the durable API; user-defined meaning is not.
+
+Green and teal, especially their darkest variants, were identified as the most
+likely perceptual collision. They are retained because the final requirement is
+three complete contrasting pairs; the pair spacing and four strong lightness
+steps are therefore functional, not decorative.
+
+The final in-app values drop the darkest source column from every family. The
+original shades 1-3 move to positions 2-4, and each new shade 1 is a solid 50%
+tint of the original shade 1 toward white. At the picker's small swatch size,
+the darkest colors lost their hue and made families hard to distinguish;
+numbered names allow this range shift without changing the palette API.
diff --git a/docs/implementation.md b/docs/implementation.md
index 2e71477..2512df7 100644
--- a/docs/implementation.md
+++ b/docs/implementation.md
@@ -775,6 +775,19 @@ the shared live levels broadcast to replace its optimistic line. Its keyboard
assertions had not started yet; the failure was the same mutable-stack ordering
dependency seen in the other drawing cases, not evidence about rename behavior.
+The future-whitespace trendline case was later quarantined for the same reason:
+it passed focused runs, then lost its selected line from `levelSeries` during a
+complete run against the shared mutable stack. Its geometry assertions had no
+series left to inspect, so rerunning would not distinguish isolation luck from
+a fix.
+
+The context-menu duplication and whole-line body-drag cases joined it after
+failing together in a focused run despite passing complete runs immediately
+beforehand. One lost its selected-line menu binding; the other measured zero
+displacement after the drag. Both depend on optimistic creation and mutable
+live geometry, so neither is trustworthy until the browser suite owns isolated
+drawing state.
+
These are explicit `node:test` skips with reasons, not deleted coverage. Re-enable
each case only after its stated external dependency is removed and repeated full
suite runs remain green.
@@ -805,6 +818,46 @@ including comments and symbols, has a selection checkbox so a mixed selection
can move together. Repeated keypresses are serialized: without that, PATCH
responses can arrive out of order and a held arrow can move a drawing backward.
+### 2026-08-13 — drawing colors became contrasting pairs
+
+The 4x4 picker was an unordered set of named colors, which made color carry
+little information once many drawings shared a chart. It is now six rows of
+four perceptually separated shades, grouped as three contrasting pairs:
+green/red, blue/orange, and teal/purple. Small gaps between each pair preserve
+that grouping in the compact picker. No timeframe, direction, or drawing type
+is assigned to a family; semantics remain entirely user-defined.
+
+Names are stable family variants such as `blue1`, with the exact hex shown in
+parentheses wherever the name appears. The native color wheel and Cancel occupy
+the final row. Existing saved colors are not rewritten; values outside the new
+24-color set are shown as custom colors. The full palette, source discussion,
+and rationale live in `docs/color_refactor.md`.
+
+Each family row can also carry a user annotation. Those labels are a local UI
+preference, not drawing data or application semantics, so they live in
+`localStorage`; a blank value falls back to the family name. The picker was
+widened rather than shrinking the 18px swatches, and the source red/orange
+values were separated further after their darkest variants proved too similar
+at that size. Stable numbered names did not change.
+
+The row label itself is the editor — there is no separate pencil mode. Inputs
+open prefilled with their displayed value and restore the family name when left
+blank. Drawing hover tooltips use three lines where available: drawing identity,
+stable color name plus hex, then the local row annotation. A custom color has no
+matching row, so its tooltip naturally stops after `custom (#RRGGBB)`.
+
+The Trendline creation tool now uses the same grouped picker as existing
+drawings. Its empty-state default is `teal1`; after levels first load it adopts
+the color of the highest-numbered persisted trendline, and successful creation
+already leaves that choice in place for the next line. Recoloring an old line
+does not change the creation default.
+
+Escape now closes any open palette or context menu and cancels the armed tool in
+the same keypress. Disarming calls the chart wrapper's existing `armTool(null)`,
+which clears a trendline's pending first anchor, preview and active gesture;
+Symbol's separate open panel is closed too. A palette no longer consumes the
+first Escape while leaving a half-drawn tool active behind it.
+
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
Trendlines project into the whitespace right of the last candle, but the axis
diff --git a/docs/multi_user.md b/docs/multi_user.md
index ddcf3cb..dddba58 100644
--- a/docs/multi_user.md
+++ b/docs/multi_user.md
@@ -73,12 +73,47 @@ with a single constant while there is one user.
Doing per-user state on flat files and migrating later is doing it twice.
+Preferences follow the same rule. Browser-only preferences may remain in
+`localStorage` until cross-device sync is worth building, but the first
+server-synced preference must not go into a global JSON file or acquire a
+dedicated database column. Add a user-keyed preference store at that point,
+initially using the same single constant as drawings.
+
+Use an extensible shape such as:
+
+```sql
+CREATE TABLE user_preferences (
+ user_id TEXT NOT NULL,
+ namespace TEXT NOT NULL,
+ value_json TEXT NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (user_id, namespace)
+);
+```
+
+Each namespace owns a validated, versioned JSON object — for example
+`drawing_palette` can hold row annotations. Adding another preference or field
+then changes application validation, not the database schema. Do not turn this
+into an unvalidated miscellaneous bag: loaders supply defaults, ignore unknown
+fields for forward compatibility, and migrate a namespace's JSON version when
+its meaning changes. Whole-object last-write-wins is sufficient initially;
+introduce revisions or optimistic concurrency only when simultaneous edits from
+multiple devices become a demonstrated problem.
+
+This store belongs to `UserView` persistence, never `MarketRuntime`. Palette
+labels, layer visibility, notification presentation and similar settings are
+owned by a person; bars, market-derived levels and feed health remain shared.
+
### Phase 3 — Identity as a first-class concept, still one user
Thread `user_id` through every query and every WebSocket subscription while the
value is still hardcoded. Nothing changes behaviourally; the difference is that
afterwards, "more than one user" is data rather than a refactor.
+The same identity must key `user_preferences`. Replacing the hardcoded value
+with an OIDC subject should require no preference-table migration and no JSON
+shape change — only the source of `user_id` changes.
+
This is the phase that makes the rest cheap, and it is invisible from outside —
which is exactly why it is worth doing before it is needed.
diff --git a/static/app.js b/static/app.js
index a0b68d5..cd5a582 100644
--- a/static/app.js
+++ b/static/app.js
@@ -115,7 +115,7 @@ createApp({
// Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawName = ref('');
- const drawColor = ref('#65b7cf');
+ const drawColor = ref('#80D8D8');
const drawWidth = ref(2);
const drawSide = ref('support');
const commentText = ref('');
@@ -139,19 +139,41 @@ createApp({
const levelWidth = ref(2);
const alertEarlyPoints = ref(null);
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 drawingColorRows = [
+ { id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
+ { id: 'red', colors: ['#FAADBC', '#F45B78', '#D13F62', '#AF2850'] },
+ { id: 'blue', colors: ['#A3CCFF', '#4699FE', '#1E76D8', '#0054B3'] },
+ { id: 'orange', colors: ['#F8C592', '#F08A24', '#D66B12', '#B94E08'] },
+ { id: 'teal', colors: ['#80D8D8', '#00B0B1', '#008E8F', '#006C6E'] },
+ { id: 'purple', colors: ['#DDBCEB', '#BB79D7', '#9858B3', '#763790'] },
];
+ const drawingColors = drawingColorRows.flatMap(row => row.colors);
const drawingColorNames = {
- '#65b7cf': 'Cyan', '#27825c': 'Green', '#45c39b': 'Mint', '#a8c85d': 'Lime',
- '#efb643': 'Amber', '#e0a34a': 'Gold', '#d97a3a': 'Orange', '#bd4545': 'Red',
- '#d96073': 'Rose', '#b07ad6': 'Purple', '#7d67c9': 'Violet', '#4f6fae': 'Blue',
- '#9fb0c4': 'Slate', '#746c60': 'Taupe', '#2c2924': 'Charcoal', '#c8992f': 'Ochre',
+ '#A6D8AA': 'green1', '#4DB155': 'green2', '#258F33': 'green3', '#006D09': 'green4',
+ '#FAADBC': 'red1', '#F45B78': 'red2', '#D13F62': 'red3', '#AF2850': 'red4',
+ '#A3CCFF': 'blue1', '#4699FE': 'blue2', '#1E76D8': 'blue3', '#0054B3': 'blue4',
+ '#F8C592': 'orange1', '#F08A24': 'orange2', '#D66B12': 'orange3', '#B94E08': 'orange4',
+ '#80D8D8': 'teal1', '#00B0B1': 'teal2', '#008E8F': 'teal3', '#006C6E': 'teal4',
+ '#DDBCEB': 'purple1', '#BB79D7': 'purple2', '#9858B3': 'purple3', '#763790': 'purple4',
+ };
+ const drawingColorName = color => {
+ const hex = String(color || '').toUpperCase();
+ return `${drawingColorNames[hex] || 'custom'} (${hex})`;
+ };
+ const colorRowLabels = ref(Object.fromEntries(drawingColorRows.map(row => [row.id, row.id])));
+ try {
+ const saved = JSON.parse(localStorage.getItem('chart-color-row-labels') || '{}');
+ for (const row of drawingColorRows) {
+ if (typeof saved[row.id] === 'string') colorRowLabels.value[row.id] = saved[row.id];
+ }
+ } catch { /* malformed UI preferences fall back to family names */ }
+ const drawingColorDescription = color => {
+ const hex = String(color || '').toUpperCase();
+ const row = drawingColorRows.find(value => value.colors.includes(hex));
+ const annotation = row && colorRowLabels.value[row.id] !== row.id
+ ? colorRowLabels.value[row.id].trim() : '';
+ return { name: drawingColorName(hex), annotation };
};
- const drawingColorName = color => drawingColorNames[color] || color;
const symbolChoices = [
{ icon: 'arrow-up', name: 'Up arrow' },
{ icon: 'arrow-down', name: 'Down arrow' },
@@ -172,6 +194,7 @@ createApp({
let retryDelay = 2000;
let timer = null;
let nudgeQueue = Promise.resolve();
+ let drawColorInitialized = false;
const barAge = computed(() => {
if (!status.value.last_bar_t) return '—';
@@ -254,6 +277,13 @@ createApp({
dataReceivedAt.value = Date.now();
chartApi.setBars(message.bars);
levels.value = message.levels || [];
+ if (!drawColorInitialized) {
+ const latest = levels.value
+ .filter(level => level.kind === 'manual' && level.slope !== 0)
+ .sort((a, b) => (b.number || 0) - (a.number || 0))[0];
+ drawColor.value = latest?.color || '#80D8D8';
+ drawColorInitialized = true;
+ }
syncVisibleLevels();
price.value = message.price;
} else if (message.type === 'bar') {
@@ -832,27 +862,27 @@ createApp({
// fixing a typo in a trendline's name deletes the trendline.
function isEditing(target) {
if (!target) return false;
- return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName);
+ if (target.isContentEditable || ['TEXTAREA', 'SELECT'].includes(target.tagName)) return true;
+ return target.tagName === 'INPUT' && target.type !== 'checkbox';
}
function handleKeydown(event) {
if (event.key === 'Escape') {
const palettes = [...document.querySelectorAll('.color-picker[open]')];
- if (palettes.length) {
- palettes.forEach(palette => { palette.open = false; });
- event.preventDefault();
- return;
- }
- if (chartApi?.dismissContextMenu()) {
- event.preventDefault();
- return;
- }
+ palettes.forEach(palette => { palette.open = false; });
+ const dismissedContext = chartApi?.dismissContextMenu();
if (armedTool.value) {
armedTool.value = null;
+ symbolPanelOpen.value = false;
chartApi.armTool(null);
event.preventDefault();
return;
}
+ if (palettes.length || dismissedContext || symbolPanelOpen.value) {
+ symbolPanelOpen.value = false;
+ event.preventDefault();
+ return;
+ }
if (hasDrawingSelection.value) {
selectedDrawing.value = null;
selectedDrawings.value = [];
@@ -927,6 +957,10 @@ createApp({
if (chartApi) chartApi.setSnap(value);
});
+ watch(colorRowLabels, value => {
+ localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
+ }, { deep: true });
+
onMounted(async () => {
chartApi = new ConfluenceChart();
// Deliberate debug handle. Chart geometry bugs are invisible from the
@@ -936,7 +970,10 @@ createApp({
chartApi.create(document.getElementById('chart'));
chartApi.setClickHandler(handleChartClick);
chartApi.setToolCompleteHandler(handleToolComplete);
- chartApi.setLineChangeHandler(updateLineGeometry);
+ chartApi.setLineChangeHandler(line => {
+ nudgeQueue = nudgeQueue.then(() => updateLineGeometry(line));
+ });
+ chartApi.setDrawingColorDescriptor(drawingColorDescription);
chartApi.setLineEndHandler(endLineHere);
chartApi.setLineDuplicateHandler(duplicateLine);
chartApi.setLineDeleteHandler(deleteLine);
@@ -956,6 +993,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
- return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorName, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
+ return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
},
}).mount('#app');
diff --git a/static/chart.js b/static/chart.js
index 1d35a9a..eea709d 100644
--- a/static/chart.js
+++ b/static/chart.js
@@ -61,6 +61,7 @@ class ConfluenceChart {
this.onCommentToggle = null;
this.onCommentMove = null;
this.onCommentSelect = null;
+ this.describeDrawingColor = null;
this.lastSnapDiagnostic = null;
}
@@ -255,6 +256,7 @@ class ConfluenceChart {
this.lineHitTarget.classList.add('chart-line-hit');
this.lineHitTarget.setAttribute('hidden', '');
this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event));
+ this.lineHitTarget.addEventListener('click', event => event.stopPropagation());
handles.appendChild(this.lineHitTarget);
for (const anchor of ['start', 'end']) {
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
@@ -1250,6 +1252,8 @@ class ConfluenceChart {
setLineChangeHandler(handler) { this.onLineChange = handler; }
+ setDrawingColorDescriptor(handler) { this.describeDrawingColor = handler; }
+
setLineEndHandler(handler) { this.onLineEnd = handler; }
setLineDuplicateHandler(handler) { this.onLineDuplicate = handler; }
@@ -1339,7 +1343,12 @@ class ConfluenceChart {
const line = this.levels.find(level => level.id === id);
this.tooltip.hidden = !line;
if (!line) return;
- this.tooltip.textContent = `#${line.number} ${line.label}`;
+ const color = this.describeDrawingColor?.(line.color);
+ this.tooltip.textContent = [
+ `#${line.number} ${line.label}`,
+ color?.name,
+ color?.annotation,
+ ].filter(Boolean).join('\n');
this.tooltip.style.left = `${param.point.x + 12}px`;
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
}
diff --git a/static/index.html b/static/index.html
index ffe03c0..9bb42ad 100644
--- a/static/index.html
+++ b/static/index.html
@@ -65,7 +65,24 @@
-
+
Colour
+
+
+
+
+
+
+
+
+
+
+
@@ -117,18 +134,22 @@
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}">