← Commit history
README.md+20−3
@@ -1,6 +1,6 @@ # adom-gantt -Interactive Gantt chart viewer for project timelines. Data-driven, dark-themed, and fully editable in the browser.+Interactive Gantt chart viewer for project timelines. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser.  ## Features @@ -24,6 +24,20 @@ Interactive Gantt chart viewer for project timelines. Data-driven, dark-themed,  **Auto-save.** Edits persist to a sidecar JSON file next to the data file. Delete the sidecar to reset. +**Fills the window.** Months stretch to the width of the pane, and stop at 34px so a long plan scrolls horizontally rather than squashing into unreadability. Resizing reflows without a reload.++**Milestone style.** Diamonds, or dashed vertical rules spanning the full chart height, colored by phase. Toggle in the header.++**Light mode.** Dark by default. The Light toggle switches to a print-friendly theme with deepened bar colors, and printing always uses it whatever the screen is set to.++**Brand phase colors.** A phase with no explicit `color` follows the Adom ramp: teal, purple, blue, then a second step of each. The ramp is theme-aware, so the same plan is on-brand on screen and legible on paper.++**Settable start date.** The timeline start is computed from the data and shown in the header. Override it to extend or trim the range; reset returns it to the data.++**Room to scroll.** You can scroll past the last row, so the bottom of the plan can sit centred in the pane instead of pinned to the bottom edge.++**Changelog.** Every edit is appended to `<datafile>-changelog.jsonl` with the item, the before and after dates, the day shift, and which gesture made it. An AI picking the project up later reads it and builds on your changes instead of overwriting them. The rev counter in the bottom-left corner is the handle: an agent notes it, then asks for only what changed since.+ ## In action  Click a bar and the inline editor opens over the chart: date pickers for start and end, plus the incoming ("needs") and outgoing ("blocks") dependencies, each removable, with a picker to add more.@@ -53,7 +67,7 @@ Create a JSON file with three sections:     "subtitle": "Short description"   },   "phases": [-    { "name": "Phase 1 — Build", "color": "#64ABFF", "start": "2026-01", "end": "2026-06" }+    { "name": "Phase 1 — Build", "start": "2026-01", "end": "2026-06" }   ],   "items": [     { "type": "group", "label": "Phase 1 — Build", "phase": 0 },@@ -63,7 +77,7 @@ Create a JSON file with three sections: } ``` -See [SKILL.md](SKILL.md) for the full field reference and recommended phase colors.+Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values.  ## Keyboard shortcuts @@ -90,6 +104,9 @@ See [SKILL.md](SKILL.md) for the full field reference and recommended phase colo | `/data` | GET | Current project data | | `/state` | GET | Saved edits (sidecar) | | `/save` | POST | Persist edits |+| `/changelog` | GET | Edit history. `?since=<rev>` for the tail, `?format=md` for the readable version |+| `/log` | POST | Append events to the changelog (used by the app, and by an AI recording its own edits) |+| `/changelog/reset` | POST | Delete the changelog | | `/import/json` | POST | Import native JSON | | `/import/csv` | POST | Import CSV | | `/import/msproject-xml` | POST | Import MS Project XML |
SKILL.md+67−14
@@ -36,7 +36,6 @@ Create a JSON file with this structure:   "phases": [     {       "name": "Phase 0 — Setup",-      "color": "#64ABFF",       "start": "2026-06",       "end": "2026-08"     }@@ -87,7 +86,7 @@ Create a JSON file with this structure: <!-- prose-lint-disable (literal legend label from the shipped example data) --> | name | string | yes | Legend label (e.g., "Phase 1 — Build") | <!-- prose-lint-enable -->-| color | hex string | yes | Bar color for this phase |+| color | hex string | no | Overrides the brand ramp for this phase. Omit it to stay theme-aware, see Phase colors below | | start | YYYY-MM | yes | Phase start month | | end | YYYY-MM | yes | Phase end month | @@ -116,7 +115,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | deps | string[] | no | Array of task IDs this depends on | | striped | boolean | no | Striped bar pattern (for ongoing work) | -**type: "milestone"**: diamond marker+**type: "milestone"**: diamond marker, or a dashed vertical rule if the user has switched milestone style  | Field | Type | Required | Description | |-------|------|----------|-------------|@@ -125,18 +124,30 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`. | phase | number | yes | Index into phases[] | | description | string | no | Tooltip body text | -### Recommended phase colors+### Phase colors -These are from the Adom brand palette and contrast well on dark backgrounds:+**Leave `color` off and the phase follows the Adom brand ramp.** That is the+recommended default: the ramp is theme-aware, so the same plan stays on-brand in+dark mode and stays legible when printed. Setting an explicit `color` opts that+phase out of theming, and it will not adapt for paper. -| Color | Hex | Good for |-|-------|-----|----------|-| Blue Light | #64ABFF | Foundation / setup phases |-| Green | #3fb950 | Build / integration phases |-| Purple | #8C6BF7 | Testing / validation phases |-| Orange | #f0883e | Optimization / tuning phases |-| Red | #f85149 | Research / experimental phases |-| Teal | #00b8b0 | Delivery / launch phases |+The ramp is teal, purple, blue, then a second step of each:++| Phase | Dark (default) | Light / print | Contrast (dark / white) |+|-------|----------------|---------------|-------------------------|+| 1 | `#00b8b1` teal | `#00a49d` | 7.65 / 3.09 |+| 2 | `#8c6bf7` purple | `#8c6bf7` | 5.01 / 3.78 |+| 3 | `#64abff` blue | `#3b95ff` | 7.94 / 3.03 |+| 4 | `#1fd7d0` | `#00726d` | second step, teal |+| 5 | `#bfb1ee` | `#5b32e0` | second step, purple |+| 6 | `#adcdf3` | `#0061ef` | second step, blue |++Phase 7 wraps back to phase 1. The light values are deepened so each clears 3:1+on white; the dark values are the brand tokens exactly.++Green, red and yellow are deliberately absent. In the Adom design system those+mean success, destructive and warning, so a phase colored red reads as a problem+rather than a category.  ## Features @@ -146,8 +157,50 @@ These are from the Adom brand palette and contrast well on dark backgrounds: - **Ctrl+Z** to undo (50-level stack) - **Auto-save** to `<datafile>-state.json` sidecar (800ms debounce) - **Auto-load** saved state on page refresh-- **Dynamic date range** computed from data (no hardcoded timeline)+- **Dynamic date range** computed from data, overridable with the start-date control in the header - **Dynamic today marker** shows current date+- **Fills the window**: months stretch to the pane width, and stop at 34px so a long plan scrolls rather than squashing+- **Milestone style toggle**: diamonds, or dashed vertical rules spanning the chart+- **Light mode** for printing (dark is the default; printing always uses the light theme)+- **Scroll past the last row** so the bottom of the plan can sit centred in the pane++View preferences (theme, milestone style, start-date override) persist in `localStorage`+per browser. They are a view concern, so they are not written to the data file.++## Read the changelog before you edit a plan++Every edit a human makes in the browser is appended to `<datafile>-changelog.jsonl`.+Read it before you touch a plan the user has been working in, so you build on their+decisions instead of resetting them.++```bash+curl -s localhost:8901/changelog?format=md            # readable, whole history+curl -s "localhost:8901/changelog?since=12&format=md" # only what changed after rev 12+curl -s localhost:8901/changelog                      # JSON: {rev, count, touched[], events[]}+```++Each event records the action, the item, the before and after dates, the day shift, and+`via` (which gesture made it: `editor`, `drag-move`, `drag-resize`, `drag-reorder`,+`batch-editor`, `undo`).++**The workflow.** Note the `rev` you saw. Next time the user asks you to change the plan,+ask for `?since=<that rev>` first. Anything in `touched[]` is a deliberate human decision:+work relative to their current dates, and say so rather than silently overwriting. If you+must overwrite one, tell the user which and why.++When you change the plan yourself, record it so the next reader can tell your work from+theirs:++```bash+curl -s -X POST localhost:8901/log -H 'Content-Type: application/json' \+  -d '{"actor":"ai","events":[{"action":"dates","id":"dvt","name":"DVT build",+       "from":{"start":"2026-07-01","end":"2026-08-15"},+       "to":{"start":"2026-07-15","end":"2026-08-29"},+       "shift":{"start":14,"end":14},"via":"agent-replan"}]}'+```++The log is append-only and never rewritten. `POST /changelog/reset` deletes it outright,+so only run it when the user asks for a clean slate.  ## State file 
examples/sample-product-launch.json+350−37
@@ -5,44 +5,357 @@     "appName": "nova-planner"   },   "phases": [-    { "name": "Phase 1 — Design", "color": "#64ABFF", "start": "2026-01", "end": "2026-04" },-    { "name": "Phase 2 — Prototype", "color": "#3fb950", "start": "2026-03", "end": "2026-07" },-    { "name": "Phase 3 — Validation", "color": "#8C6BF7", "start": "2026-07", "end": "2026-10" },-    { "name": "Phase 4 — Production", "color": "#f0883e", "start": "2026-10", "end": "2027-02" }+    {+      "name": "Phase 1 — Design",+      "start": "2026-01",+      "end": "2026-04"+    },+    {+      "name": "Phase 2 — Prototype",+      "start": "2026-03",+      "end": "2026-07"+    },+    {+      "name": "Phase 3 — Validation",+      "start": "2026-07",+      "end": "2026-10"+    },+    {+      "name": "Phase 4 — Production",+      "start": "2026-10",+      "end": "2027-02"+    }   ],   "items": [-    { "type": "group", "label": "Phase 1 — Design (Jan–Apr 2026)", "phase": 0 },-    { "type": "task", "id": "reqs", "name": "Requirements gathering", "start": "2026-01-05", "end": "2026-02-01", "phase": 0, "status": "live", "description": "Stakeholder interviews, market analysis, feature prioritization." },-    { "type": "task", "id": "arch", "name": "System architecture", "start": "2026-01-15", "end": "2026-03-01", "phase": 0, "status": "live", "description": "Hardware block diagram, firmware partitioning, cloud API design.", "deps": ["reqs"] },-    { "type": "task", "id": "id", "name": "Industrial design", "start": "2026-01-20", "end": "2026-03-15", "phase": 0, "status": "planned", "description": "Enclosure CAD, acoustic simulation, thermal analysis." },-    { "type": "task", "id": "sch", "name": "Schematic capture", "start": "2026-02-10", "end": "2026-03-20", "phase": 0, "status": "planned", "description": "4-layer PCB, audio codec, WiFi/BT SoC, MEMS mic array.", "deps": ["arch"] },-    { "type": "task", "id": "fw-plan", "name": "Firmware architecture", "start": "2026-02-15", "end": "2026-03-10", "phase": 0, "status": "not-started", "description": "RTOS selection, audio pipeline design, OTA update strategy.", "deps": ["arch"] },-    { "type": "task", "id": "layout", "name": "PCB layout", "start": "2026-03-01", "end": "2026-04-10", "phase": 0, "status": "not-started", "description": "Placement, routing, impedance-controlled traces for audio.", "deps": ["sch"] },-    { "type": "milestone", "name": "M1: Design review complete", "date": "2026-04-10", "phase": 0, "description": "Schematic, layout, and ID signed off." },--    { "type": "group", "label": "Phase 2 — Prototype (Mar–Jul 2026)", "phase": 1 },-    { "type": "task", "id": "pcb-fab", "name": "PCB fabrication (EVT)", "start": "2026-04-11", "end": "2026-05-01", "phase": 1, "status": "not-started", "description": "3 board revisions, 20 units.", "deps": ["layout"] },-    { "type": "task", "id": "smt", "name": "SMT assembly", "start": "2026-05-01", "end": "2026-05-20", "phase": 1, "status": "not-started", "description": "Pick-and-place, reflow, visual inspection.", "deps": ["pcb-fab"] },-    { "type": "task", "id": "fw-core", "name": "Firmware core bring-up", "start": "2026-03-15", "end": "2026-06-01", "phase": 1, "status": "not-started", "description": "Boot, audio I2S, WiFi stack, BLE pairing.", "deps": ["fw-plan"] },-    { "type": "task", "id": "enclosure", "name": "Enclosure prototyping", "start": "2026-04-01", "end": "2026-06-15", "phase": 1, "status": "not-started", "description": "3D prints, soft tooling, acoustic testing.", "deps": ["id"] },-    { "type": "task", "id": "cloud", "name": "Cloud API + app", "start": "2026-04-01", "end": "2026-07-01", "phase": 1, "status": "not-started", "description": "Device provisioning, OTA, voice control integration.", "deps": ["arch"] },-    { "type": "task", "id": "int", "name": "HW/FW integration", "start": "2026-05-20", "end": "2026-07-01", "phase": 1, "status": "not-started", "description": "Board bringup, audio tuning, thermal validation.", "deps": ["smt", "fw-core"] },-    { "type": "milestone", "name": "M2: EVT functional", "date": "2026-07-01", "phase": 1, "description": "20 working units, audio quality validated." },--    { "type": "group", "label": "Phase 3 — Validation (Jul–Oct 2026)", "phase": 2 },-    { "type": "task", "id": "dvt", "name": "DVT build (100 units)", "start": "2026-07-01", "end": "2026-08-01", "phase": 2, "status": "not-started", "description": "Production tooling, injection-molded enclosures.", "deps": ["int", "enclosure"] },-    { "type": "task", "id": "cert", "name": "Regulatory certification", "start": "2026-07-15", "end": "2026-09-15", "phase": 2, "status": "not-started", "description": "FCC, CE, UL, Bluetooth SIG.", "deps": ["dvt"] },-    { "type": "task", "id": "fw-final", "name": "Firmware feature-complete", "start": "2026-07-01", "end": "2026-09-01", "phase": 2, "status": "not-started", "description": "Voice assistant, EQ presets, multi-room, OTA.", "deps": ["fw-core", "cloud"] },-    { "type": "task", "id": "qa", "name": "QA + reliability testing", "start": "2026-08-01", "end": "2026-10-01", "phase": 2, "status": "not-started", "description": "Drop test, thermal cycling, 1000-hour burn-in, audio golden ear.", "deps": ["dvt", "fw-final"] },-    { "type": "task", "id": "beta", "name": "Beta program (50 users)", "start": "2026-08-15", "end": "2026-10-01", "phase": 2, "status": "not-started", "description": "Field testing, NPS survey, bug triage.", "deps": ["dvt", "fw-final"] },-    { "type": "milestone", "name": "M3: DVT sign-off", "date": "2026-10-01", "phase": 2, "description": "Certification passed, QA green, beta feedback incorporated." },--    { "type": "group", "label": "Phase 4 — Production (Oct 2026–Feb 2027)", "phase": 3 },-    { "type": "task", "id": "pvt", "name": "PVT build (1000 units)", "start": "2026-10-01", "end": "2026-11-15", "phase": 3, "status": "not-started", "description": "Production-intent tooling, line qualification.", "deps": ["qa", "cert"] },-    { "type": "task", "id": "pkg", "name": "Packaging + unboxing design", "start": "2026-10-01", "end": "2026-11-01", "phase": 3, "status": "not-started", "description": "Retail box, quick-start guide, accessory kit." },-    { "type": "task", "id": "mfg", "name": "Mass production ramp", "start": "2026-11-15", "end": "2027-01-15", "phase": 3, "status": "not-started", "description": "5,000 units/month target, yield optimization.", "deps": ["pvt", "pkg"] },-    { "type": "task", "id": "launch-prep", "name": "Launch preparation", "start": "2026-12-01", "end": "2027-01-31", "phase": 3, "status": "not-started", "description": "Marketing site, press kits, retail channel setup, support training." },-    { "type": "task", "id": "ship", "name": "First customer shipments", "start": "2027-01-15", "end": "2027-02-15", "phase": 3, "status": "not-started", "description": "Warehouse fulfillment, retail distribution.", "deps": ["mfg", "launch-prep"] },-    { "type": "milestone", "name": "M4: Product launch", "date": "2027-02-01", "phase": 3, "description": "General availability, press embargo lifts." }+    {+      "type": "group",+      "label": "Phase 1 — Design (Jan–Apr 2026)",+      "phase": 0+    },+    {+      "type": "task",+      "id": "reqs",+      "name": "Requirements gathering",+      "start": "2026-01-05",+      "end": "2026-02-01",+      "phase": 0,+      "status": "live",+      "description": "Stakeholder interviews, market analysis, feature prioritization."+    },+    {+      "type": "task",+      "id": "arch",+      "name": "System architecture",+      "start": "2026-01-15",+      "end": "2026-03-01",+      "phase": 0,+      "status": "live",+      "description": "Hardware block diagram, firmware partitioning, cloud API design.",+      "deps": [+        "reqs"+      ]+    },+    {+      "type": "task",+      "id": "id",+      "name": "Industrial design",+      "start": "2026-01-20",+      "end": "2026-03-15",+      "phase": 0,+      "status": "planned",+      "description": "Enclosure CAD, acoustic simulation, thermal analysis."+    },+    {+      "type": "task",+      "id": "sch",+      "name": "Schematic capture",+      "start": "2026-02-10",+      "end": "2026-03-20",+      "phase": 0,+      "status": "planned",+      "description": "4-layer PCB, audio codec, WiFi/BT SoC, MEMS mic array.",+      "deps": [+        "arch"+      ]+    },+    {+      "type": "task",+      "id": "fw-plan",+      "name": "Firmware architecture",+      "start": "2026-02-15",+      "end": "2026-03-10",+      "phase": 0,+      "status": "not-started",+      "description": "RTOS selection, audio pipeline design, OTA update strategy.",+      "deps": [+        "arch"+      ]+    },+    {+      "type": "task",+      "id": "layout",+      "name": "PCB layout",+      "start": "2026-03-01",+      "end": "2026-04-10",+      "phase": 0,+      "status": "not-started",+      "description": "Placement, routing, impedance-controlled traces for audio.",+      "deps": [+        "sch"+      ]+    },+    {+      "type": "milestone",+      "name": "M1: Design review complete",+      "date": "2026-04-10",+      "phase": 0,+      "description": "Schematic, layout, and ID signed off."+    },+    {+      "type": "group",+      "label": "Phase 2 — Prototype (Mar–Jul 2026)",+      "phase": 1+    },+    {+      "type": "task",+      "id": "pcb-fab",+      "name": "PCB fabrication (EVT)",+      "start": "2026-04-11",+      "end": "2026-05-01",+      "phase": 1,+      "status": "not-started",+      "description": "3 board revisions, 20 units.",+      "deps": [+        "layout"+      ]+    },+    {+      "type": "task",+      "id": "smt",+      "name": "SMT assembly",+      "start": "2026-05-01",+      "end": "2026-05-20",+      "phase": 1,+      "status": "not-started",+      "description": "Pick-and-place, reflow, visual inspection.",+      "deps": [+        "pcb-fab"+      ]+    },+    {+      "type": "task",+      "id": "fw-core",+      "name": "Firmware core bring-up",+      "start": "2026-03-15",+      "end": "2026-06-01",+      "phase": 1,+      "status": "not-started",+      "description": "Boot, audio I2S, WiFi stack, BLE pairing.",+      "deps": [+        "fw-plan"+      ]+    },+    {+      "type": "task",+      "id": "enclosure",+      "name": "Enclosure prototyping",+      "start": "2026-04-01",+      "end": "2026-06-15",+      "phase": 1,+      "status": "not-started",+      "description": "3D prints, soft tooling, acoustic testing.",+      "deps": [+        "id"+      ]+    },+    {+      "type": "task",+      "id": "cloud",+      "name": "Cloud API + app",+      "start": "2026-04-01",+      "end": "2026-07-01",+      "phase": 1,+      "status": "not-started",+      "description": "Device provisioning, OTA, voice control integration.",+      "deps": [+        "arch"+      ]+    },+    {+      "type": "task",+      "id": "int",+      "name": "HW/FW integration",+      "start": "2026-05-20",+      "end": "2026-07-01",+      "phase": 1,+      "status": "not-started",+      "description": "Board bringup, audio tuning, thermal validation.",+      "deps": [+        "smt",+        "fw-core"+      ]+    },+    {+      "type": "milestone",+      "name": "M2: EVT functional",+      "date": "2026-07-01",+      "phase": 1,+      "description": "20 working units, audio quality validated."+    },+    {+      "type": "group",+      "label": "Phase 3 — Validation (Jul–Oct 2026)",+      "phase": 2+    },+    {+      "type": "task",+      "id": "dvt",+      "name": "DVT build (100 units)",+      "start": "2026-07-01",+      "end": "2026-08-01",+      "phase": 2,+      "status": "not-started",+      "description": "Production tooling, injection-molded enclosures.",+      "deps": [+        "int",+        "enclosure"+      ]+    },+    {+      "type": "task",+      "id": "cert",+      "name": "Regulatory certification",+      "start": "2026-07-15",+      "end": "2026-09-15",+      "phase": 2,+      "status": "not-started",+      "description": "FCC, CE, UL, Bluetooth SIG.",+      "deps": [+        "dvt"+      ]+    },+    {+      "type": "task",+      "id": "fw-final",+      "name": "Firmware feature-complete",+      "start": "2026-07-01",+      "end": "2026-09-01",+      "phase": 2,+      "status": "not-started",+      "description": "Voice assistant, EQ presets, multi-room, OTA.",+      "deps": [+        "fw-core",+        "cloud"+      ]+    },+    {+      "type": "task",+      "id": "qa",+      "name": "QA + reliability testing",+      "start": "2026-08-01",+      "end": "2026-10-01",+      "phase": 2,+      "status": "not-started",+      "description": "Drop test, thermal cycling, 1000-hour burn-in, audio golden ear.",+      "deps": [+        "dvt",+        "fw-final"+      ]+    },+    {+      "type": "task",+      "id": "beta",+      "name": "Beta program (50 users)",+      "start": "2026-08-15",+      "end": "2026-10-01",+      "phase": 2,+      "status": "not-started",+      "description": "Field testing, NPS survey, bug triage.",+      "deps": [+        "dvt",+        "fw-final"+      ]+    },+    {+      "type": "milestone",+      "name": "M3: DVT sign-off",+      "date": "2026-10-01",+      "phase": 2,+      "description": "Certification passed, QA green, beta feedback incorporated."+    },+    {+      "type": "group",+      "label": "Phase 4 — Production (Oct 2026–Feb 2027)",+      "phase": 3+    },+    {+      "type": "task",+      "id": "pvt",+      "name": "PVT build (1000 units)",+      "start": "2026-10-01",+      "end": "2026-11-15",+      "phase": 3,+      "status": "not-started",+      "description": "Production-intent tooling, line qualification.",+      "deps": [+        "qa",+        "cert"+      ]+    },+    {+      "type": "task",+      "id": "pkg",+      "name": "Packaging + unboxing design",+      "start": "2026-10-01",+      "end": "2026-11-01",+      "phase": 3,+      "status": "not-started",+      "description": "Retail box, quick-start guide, accessory kit."+    },+    {+      "type": "task",+      "id": "mfg",+      "name": "Mass production ramp",+      "start": "2026-11-15",+      "end": "2027-01-15",+      "phase": 3,+      "status": "not-started",+      "description": "5,000 units/month target, yield optimization.",+      "deps": [+        "pvt",+        "pkg"+      ]+    },+    {+      "type": "task",+      "id": "launch-prep",+      "name": "Launch preparation",+      "start": "2026-12-01",+      "end": "2027-01-31",+      "phase": 3,+      "status": "not-started",+      "description": "Marketing site, press kits, retail channel setup, support training."+    },+    {+      "type": "task",+      "id": "ship",+      "name": "First customer shipments",+      "start": "2027-01-15",+      "end": "2027-02-15",+      "phase": 3,+      "status": "not-started",+      "description": "Warehouse fulfillment, retail distribution.",+      "deps": [+        "mfg",+        "launch-prep"+      ]+    },+    {+      "type": "milestone",+      "name": "M4: Product launch",+      "date": "2027-02-01",+      "phase": 3,+      "description": "General availability, press embargo lifts."+    }   ] }
examples/smt-line.json+465−52
@@ -5,59 +5,472 @@     "appName": "smt-planner"   },   "phases": [-    { "name": "Phase 0 — Foundation", "color": "#64ABFF", "start": "2026-06", "end": "2026-08" },-    { "name": "Phase 1 — SPI + Loop 1", "color": "#3fb950", "start": "2026-09", "end": "2027-02" },-    { "name": "Phase 2 — AOI + Loop 2", "color": "#8C6BF7", "start": "2027-03", "end": "2027-08" },-    { "name": "Phase 3 — Reflow + Predict", "color": "#f0883e", "start": "2027-09", "end": "2028-06" },-    { "name": "Phase 4 — Research", "color": "#f85149", "start": "2028-07", "end": "2028-12" }+    {+      "name": "Phase 0 — Foundation",+      "start": "2026-06",+      "end": "2026-08"+    },+    {+      "name": "Phase 1 — SPI + Loop 1",+      "start": "2026-09",+      "end": "2027-02"+    },+    {+      "name": "Phase 2 — AOI + Loop 2",+      "start": "2027-03",+      "end": "2027-08"+    },+    {+      "name": "Phase 3 — Reflow + Predict",+      "start": "2027-09",+      "end": "2028-06"+    },+    {+      "name": "Phase 4 — Research",+      "start": "2028-07",+      "end": "2028-12"+    }   ],   "items": [-    { "type": "group", "label": "Phase 0 — Foundation (Jun–Aug 2026)", "phase": 0 },-    { "type": "task", "id": "day1", "name": "Essemtec Day-1 Verification", "start": "2026-06-01", "end": "2026-07-15", "phase": 0, "status": "planned", "description": "10 empirical tests on dev ePlace install. Blocks PumaBridge file-write design decisions." },-    { "type": "task", "id": "i2s", "name": "i2s Evaluation", "start": "2026-06-01", "end": "2026-07-31", "phase": 0, "status": "planned", "description": "Evaluate i2s 2D inspection: data access via CFX/eData, measurement quality, licensing ($38K)." },-    { "type": "task", "id": "pg", "name": "PostgreSQL Schema", "start": "2026-06-01", "end": "2026-07-15", "phase": 0, "status": "not-started", "description": "Board-GUID-keyed tables: spi_measurements, aoi_measurements, traceability." },-    { "type": "task", "id": "rmq", "name": "RabbitMQ Broker", "start": "2026-06-01", "end": "2026-06-30", "phase": 0, "status": "not-started", "description": "AMQP broker for CFX ingest and WriteOp routing." },-    { "type": "task", "id": "cfx", "name": "CFX Ingestor (Rust)", "start": "2026-06-15", "end": "2026-08-31", "phase": 0, "status": "not-started", "description": "Subscribes to Puma CFX bus, transforms IPC-2591 JSON to Postgres rows.", "deps": ["rmq", "pg"] },-    { "type": "task", "id": "env", "name": "Environmental Sensors", "start": "2026-06-01", "end": "2026-08-15", "phase": 0, "status": "not-started", "description": "Temperature/humidity sensors for Loop 7 SPC bands." },-    { "type": "task", "id": "pb03", "name": "PumaBridge v0.1–v0.3", "start": "2026-06-15", "end": "2026-08-31", "phase": 0, "status": "planned", "description": "W1: env setup. W2: read-only eData. W3: recipe writes. W4: file-watcher + git.", "deps": ["day1"] },-    { "type": "task", "id": "pfp", "name": "puma-file-prep (ongoing)", "start": "2026-05-01", "end": "2027-02-28", "phase": 0, "status": "live", "striped": true, "description": "279 packages, 61 components, 55/55 E2E tests. Ongoing: AMQP transport, UI polish." },-    { "type": "task", "id": "pc", "name": "puma-converter (ongoing)", "start": "2026-05-01", "end": "2026-12-31", "phase": 0, "status": "live", "striped": true, "description": "CAD/BOM import engine. KiCad to Puma XML. Stable, incremental." },-    { "type": "task", "id": "inv", "name": "pnp-inventory (ongoing)", "start": "2026-05-01", "end": "2026-12-31", "phase": 0, "status": "live", "striped": true, "description": "vendor-cross-ref (port 8893) + job-inventory-check (port 8895)." },-    { "type": "milestone", "name": "M1: Data backbone operational", "date": "2026-08-31", "phase": 0, "description": "Postgres + RabbitMQ + CFX ingestor running. Puma telemetry flowing." },--    { "type": "group", "label": "Phase 1 — SPI + Loop 1 (Sep 2026–Feb 2027)", "phase": 1 },-    { "type": "task", "id": "spi-src", "name": "Source Used 3D SPI", "start": "2026-07-01", "end": "2026-10-31", "phase": 1, "status": "not-started", "description": "Scout used Koh Young KY8030 or similar. $15K–$50K. 3–6 month lead." },-    { "type": "task", "id": "spi-int", "name": "3D SPI Integration", "start": "2026-10-01", "end": "2027-01-31", "phase": 1, "status": "not-started", "description": "Install, integrate with CFX bus, validate per-pad volume data to Postgres.", "deps": ["spi-src", "cfx"] },-    { "type": "task", "id": "spc", "name": "SPC Engine (Rust)", "start": "2026-09-01", "end": "2026-12-31", "phase": 1, "status": "not-started", "description": "Western Electric control-chart rules. Fires alerts on process drift.", "deps": ["pg"] },-    { "type": "task", "id": "pb10", "name": "PumaBridge v0.4–v1.0", "start": "2026-09-01", "end": "2026-11-30", "phase": 1, "status": "planned", "description": "W5: Package file writes. W6: remaining file-writers, observability, pre-flight checks.", "deps": ["pb03"] },-    { "type": "task", "id": "loop1", "name": "Loop 1: Dispense → SPI (advisory)", "start": "2026-12-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Proportional control on per-pad-class jet valve shot params. Advisory mode.", "deps": ["spi-int", "spc", "pb10"] },-    { "type": "task", "id": "loop7", "name": "Loop 7: Environment → SPC", "start": "2026-10-01", "end": "2027-01-31", "phase": 1, "status": "not-started", "description": "Temperature/humidity-band SPC limit adjustment.", "deps": ["spc", "env"] },-    { "type": "task", "id": "loop4", "name": "Loop 4: Lot Change Screening", "start": "2027-01-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Tighten SPC limits on first boards after reel change.", "deps": ["spc"] },-    { "type": "task", "id": "mol01", "name": "Molecule-Inventory v0.1", "start": "2026-11-01", "end": "2027-02-28", "phase": 1, "status": "not-started", "description": "Standalone app. Reel tracking, feeder-slot mapping, MSL state, stock levels." },-    { "type": "milestone", "name": "M2: SPI data flowing", "date": "2027-01-15", "phase": 1, "description": "Per-pad paste volume data in Postgres. SPC engine monitoring." },-    { "type": "milestone", "name": "M3: Loop 1 advisory", "date": "2027-02-28", "phase": 1, "description": "Dispense closed-loop running in advisory mode." },--    { "type": "group", "label": "Phase 2 — AOI + Loop 2 (Mar–Aug 2027)", "phase": 2 },-    { "type": "task", "id": "i2s-int", "name": "i2s Integration (if viable)", "start": "2027-01-01", "end": "2027-04-30", "phase": 2, "status": "planned", "description": "Integrate Puma i2s 2D pre-reflow inspection into supervisor. Depends on Q-i2s eval.", "deps": ["i2s"] },-    { "type": "task", "id": "aoi-src", "name": "Source Used Post-Reflow AOI", "start": "2027-01-01", "end": "2027-05-31", "phase": 2, "status": "not-started", "description": "Used Koh Young Zenith or similar. $25K–$80K. Required for Loop 3 and Loop 6." },-    { "type": "task", "id": "aoi-int", "name": "Post-Reflow AOI Integration", "start": "2027-04-01", "end": "2027-07-31", "phase": 2, "status": "not-started", "description": "Install, integrate CFX, validate per-joint solder data.", "deps": ["aoi-src", "cfx"] },-    { "type": "task", "id": "loop2", "name": "Loop 2: Placement → AOI (Kalman)", "start": "2027-03-01", "end": "2027-07-31", "phase": 2, "status": "not-started", "description": "Kalman filter corrects feeder/nozzle offsets from i2s or external pre-reflow AOI.", "deps": ["i2s-int", "pb10"] },-    { "type": "task", "id": "loop6", "name": "Loop 6: Test → Root Cause", "start": "2027-05-01", "end": "2027-08-31", "phase": 2, "status": "not-started", "description": "Correlates test failures to upstream anomalies. Mahalanobis scoring. RCA dossier.", "deps": ["aoi-int", "spc"] },-    { "type": "task", "id": "dash", "name": "Operator Dashboard v1", "start": "2027-04-01", "end": "2027-08-31", "phase": 2, "status": "not-started", "description": "Web app: live SPC charts, alerts, traceability, RCA dossiers." },-    { "type": "milestone", "name": "M4: AOI/i2s data flowing", "date": "2027-05-31", "phase": 2, "description": "Pre-reflow inspection data in Postgres." },-    { "type": "milestone", "name": "M5: Loop 2 closed-loop", "date": "2027-08-31", "phase": 2, "description": "Placement correction running with automatic writebacks." },--    { "type": "group", "label": "Phase 3 — Reflow + Predictive (Sep 2027–Jun 2028)", "phase": 3 },-    { "type": "task", "id": "loop3", "name": "Loop 3: Reflow Bayesian Opt.", "start": "2027-09-01", "end": "2028-03-31", "phase": 3, "status": "not-started", "description": "Bayesian optimization (BoTorch/Ax) of reflow profile. Human-in-the-loop.", "deps": ["aoi-int"] },-    { "type": "task", "id": "loop5", "name": "Loop 5: Feeder Health", "start": "2027-09-01", "end": "2028-02-28", "phase": 3, "status": "not-started", "description": "Predictive maintenance. Supervisor module. Pickup retry anomaly detection." },-    { "type": "task", "id": "loop1cl", "name": "Loop 1 → Closed-Loop", "start": "2027-09-01", "end": "2027-12-31", "phase": 3, "status": "not-started", "description": "Promote Loop 1 from advisory to automatic writebacks.", "deps": ["loop1"] },-    { "type": "task", "id": "mol10", "name": "Molecule-Inventory v1.0", "start": "2027-09-01", "end": "2028-01-31", "phase": 3, "status": "not-started", "description": "Full stock integration, MSL compliance, BOM cross-check.", "deps": ["mol01"] },-    { "type": "milestone", "name": "M6: Loop 3 BO running", "date": "2028-03-31", "phase": 3, "description": "Reflow Bayesian optimization operational." },-    { "type": "milestone", "name": "M7: Feeder predictive maint.", "date": "2028-02-28", "phase": 3, "description": "Feeder health monitoring and alerting live." },--    { "type": "group", "label": "Phase 4 — Research (Jul 2028+)", "phase": 4 },-    { "type": "task", "id": "classif", "name": "Custom Defect Classifiers", "start": "2028-07-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Train custom vision models on accumulated production data." },-    { "type": "task", "id": "loop8", "name": "Loop 8: Transfer Learning", "start": "2028-07-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Seed new product recipes from similar existing products." },-    { "type": "task", "id": "loop9", "name": "Loop 9: LLM Operator Assist", "start": "2028-09-01", "end": "2028-12-31", "phase": 4, "status": "not-started", "description": "Plain-English diagnostics and recommendations." }+    {+      "type": "group",+      "label": "Phase 0 — Foundation (Jun–Aug 2026)",+      "phase": 0+    },+    {+      "type": "task",+      "id": "day1",+      "name": "Essemtec Day-1 Verification",+      "start": "2026-06-01",+      "end": "2026-07-15",+      "phase": 0,+      "status": "planned",+      "description": "10 empirical tests on dev ePlace install. Blocks PumaBridge file-write design decisions."+    },+    {+      "type": "task",+      "id": "i2s",+      "name": "i2s Evaluation",+      "start": "2026-06-01",+      "end": "2026-07-31",+      "phase": 0,+      "status": "planned",+      "description": "Evaluate i2s 2D inspection: data access via CFX/eData, measurement quality, licensing ($38K)."+    },+    {+      "type": "task",+      "id": "pg",+      "name": "PostgreSQL Schema",+      "start": "2026-06-01",+      "end": "2026-07-15",+      "phase": 0,+      "status": "not-started",+      "description": "Board-GUID-keyed tables: spi_measurements, aoi_measurements, traceability."+    },+    {+      "type": "task",+      "id": "rmq",+      "name": "RabbitMQ Broker",+      "start": "2026-06-01",+      "end": "2026-06-30",+      "phase": 0,+      "status": "not-started",+      "description": "AMQP broker for CFX ingest and WriteOp routing."+    },+    {+      "type": "task",+      "id": "cfx",+      "name": "CFX Ingestor (Rust)",+      "start": "2026-06-15",+      "end": "2026-08-31",+      "phase": 0,+      "status": "not-started",+      "description": "Subscribes to Puma CFX bus, transforms IPC-2591 JSON to Postgres rows.",+      "deps": [+        "rmq",+        "pg"+      ]+    },+    {+      "type": "task",+      "id": "env",+      "name": "Environmental Sensors",+      "start": "2026-06-01",+      "end": "2026-08-15",+      "phase": 0,+      "status": "not-started",+      "description": "Temperature/humidity sensors for Loop 7 SPC bands."+    },+    {+      "type": "task",+      "id": "pb03",+      "name": "PumaBridge v0.1–v0.3",+      "start": "2026-06-15",+      "end": "2026-08-31",+      "phase": 0,+      "status": "planned",+      "description": "W1: env setup. W2: read-only eData. W3: recipe writes. W4: file-watcher + git.",+      "deps": [+        "day1"+      ]+    },+    {+      "type": "task",+      "id": "pfp",+      "name": "puma-file-prep (ongoing)",+      "start": "2026-05-01",+      "end": "2027-02-28",+      "phase": 0,+      "status": "live",+      "striped": true,+      "description": "279 packages, 61 components, 55/55 E2E tests. Ongoing: AMQP transport, UI polish."+    },+    {+      "type": "task",+      "id": "pc",+      "name": "puma-converter (ongoing)",+      "start": "2026-05-01",+      "end": "2026-12-31",+      "phase": 0,+      "status": "live",+      "striped": true,+      "description": "CAD/BOM import engine. KiCad to Puma XML. Stable, incremental."+    },+    {+      "type": "task",+      "id": "inv",+      "name": "pnp-inventory (ongoing)",+      "start": "2026-05-01",+      "end": "2026-12-31",+      "phase": 0,+      "status": "live",+      "striped": true,+      "description": "vendor-cross-ref (port 8893) + job-inventory-check (port 8895)."+    },+    {+      "type": "milestone",+      "name": "M1: Data backbone operational",+      "date": "2026-08-31",+      "phase": 0,+      "description": "Postgres + RabbitMQ + CFX ingestor running. Puma telemetry flowing."+    },+    {+      "type": "group",+      "label": "Phase 1 — SPI + Loop 1 (Sep 2026–Feb 2027)",+      "phase": 1+    },+    {+      "type": "task",+      "id": "spi-src",+      "name": "Source Used 3D SPI",+      "start": "2026-07-01",+      "end": "2026-10-31",+      "phase": 1,+      "status": "not-started",+      "description": "Scout used Koh Young KY8030 or similar. $15K–$50K. 3–6 month lead."+    },+    {+      "type": "task",+      "id": "spi-int",+      "name": "3D SPI Integration",+      "start": "2026-10-01",+      "end": "2027-01-31",+      "phase": 1,+      "status": "not-started",+      "description": "Install, integrate with CFX bus, validate per-pad volume data to Postgres.",+      "deps": [+        "spi-src",+        "cfx"+      ]+    },+    {+      "type": "task",+      "id": "spc",+      "name": "SPC Engine (Rust)",+      "start": "2026-09-01",+      "end": "2026-12-31",+      "phase": 1,+      "status": "not-started",+      "description": "Western Electric control-chart rules. Fires alerts on process drift.",+      "deps": [+        "pg"+      ]+    },+    {+      "type": "task",+      "id": "pb10",+      "name": "PumaBridge v0.4–v1.0",+      "start": "2026-09-01",+      "end": "2026-11-30",+      "phase": 1,+      "status": "planned",+      "description": "W5: Package file writes. W6: remaining file-writers, observability, pre-flight checks.",+      "deps": [+        "pb03"+      ]+    },+    {+      "type": "task",+      "id": "loop1",+      "name": "Loop 1: Dispense → SPI (advisory)",+      "start": "2026-12-01",+      "end": "2027-02-28",+      "phase": 1,+      "status": "not-started",+      "description": "Proportional control on per-pad-class jet valve shot params. Advisory mode.",+      "deps": [+        "spi-int",+        "spc",+        "pb10"+      ]+    },+    {+      "type": "task",+      "id": "loop7",+      "name": "Loop 7: Environment → SPC",+      "start": "2026-10-01",+      "end": "2027-01-31",+      "phase": 1,+      "status": "not-started",+      "description": "Temperature/humidity-band SPC limit adjustment.",+      "deps": [+        "spc",+        "env"+      ]+    },+    {+      "type": "task",+      "id": "loop4",+      "name": "Loop 4: Lot Change Screening",+      "start": "2027-01-01",+      "end": "2027-02-28",+      "phase": 1,+      "status": "not-started",+      "description": "Tighten SPC limits on first boards after reel change.",+      "deps": [+        "spc"+      ]+    },+    {+      "type": "task",+      "id": "mol01",+      "name": "Molecule-Inventory v0.1",+      "start": "2026-11-01",+      "end": "2027-02-28",+      "phase": 1,+      "status": "not-started",+      "description": "Standalone app. Reel tracking, feeder-slot mapping, MSL state, stock levels."+    },+    {+      "type": "milestone",+      "name": "M2: SPI data flowing",+      "date": "2027-01-15",+      "phase": 1,+      "description": "Per-pad paste volume data in Postgres. SPC engine monitoring."+    },+    {+      "type": "milestone",+      "name": "M3: Loop 1 advisory",+      "date": "2027-02-28",+      "phase": 1,+      "description": "Dispense closed-loop running in advisory mode."+    },+    {+      "type": "group",+      "label": "Phase 2 — AOI + Loop 2 (Mar–Aug 2027)",+      "phase": 2+    },+    {+      "type": "task",+      "id": "i2s-int",+      "name": "i2s Integration (if viable)",+      "start": "2027-01-01",+      "end": "2027-04-30",+      "phase": 2,+      "status": "planned",+      "description": "Integrate Puma i2s 2D pre-reflow inspection into supervisor. Depends on Q-i2s eval.",+      "deps": [+        "i2s"+      ]+    },+    {+      "type": "task",+      "id": "aoi-src",+      "name": "Source Used Post-Reflow AOI",+      "start": "2027-01-01",+      "end": "2027-05-31",+      "phase": 2,+      "status": "not-started",+      "description": "Used Koh Young Zenith or similar. $25K–$80K. Required for Loop 3 and Loop 6."+    },+    {+      "type": "task",+      "id": "aoi-int",+      "name": "Post-Reflow AOI Integration",+      "start": "2027-04-01",+      "end": "2027-07-31",+      "phase": 2,+      "status": "not-started",+      "description": "Install, integrate CFX, validate per-joint solder data.",+      "deps": [+        "aoi-src",+        "cfx"+      ]+    },+    {+      "type": "task",+      "id": "loop2",+      "name": "Loop 2: Placement → AOI (Kalman)",+      "start": "2027-03-01",+      "end": "2027-07-31",+      "phase": 2,+      "status": "not-started",+      "description": "Kalman filter corrects feeder/nozzle offsets from i2s or external pre-reflow AOI.",+      "deps": [+        "i2s-int",+        "pb10"+      ]+    },+    {+      "type": "task",+      "id": "loop6",+      "name": "Loop 6: Test → Root Cause",+      "start": "2027-05-01",+      "end": "2027-08-31",+      "phase": 2,+      "status": "not-started",+      "description": "Correlates test failures to upstream anomalies. Mahalanobis scoring. RCA dossier.",+      "deps": [+        "aoi-int",+        "spc"+      ]+    },+    {+      "type": "task",+      "id": "dash",+      "name": "Operator Dashboard v1",+      "start": "2027-04-01",+      "end": "2027-08-31",+      "phase": 2,+      "status": "not-started",+      "description": "Web app: live SPC charts, alerts, traceability, RCA dossiers."+    },+    {+      "type": "milestone",+      "name": "M4: AOI/i2s data flowing",+      "date": "2027-05-31",+      "phase": 2,+      "description": "Pre-reflow inspection data in Postgres."+    },+    {+      "type": "milestone",+      "name": "M5: Loop 2 closed-loop",+      "date": "2027-08-31",+      "phase": 2,+      "description": "Placement correction running with automatic writebacks."+    },+    {+      "type": "group",+      "label": "Phase 3 — Reflow + Predictive (Sep 2027–Jun 2028)",+      "phase": 3+    },+    {+      "type": "task",+      "id": "loop3",+      "name": "Loop 3: Reflow Bayesian Opt.",+      "start": "2027-09-01",+      "end": "2028-03-31",+      "phase": 3,+      "status": "not-started",+      "description": "Bayesian optimization (BoTorch/Ax) of reflow profile. Human-in-the-loop.",+      "deps": [+        "aoi-int"+      ]+    },+    {+      "type": "task",+      "id": "loop5",+      "name": "Loop 5: Feeder Health",+      "start": "2027-09-01",+      "end": "2028-02-28",+      "phase": 3,+      "status": "not-started",+      "description": "Predictive maintenance. Supervisor module. Pickup retry anomaly detection."+    },+    {+      "type": "task",+      "id": "loop1cl",+      "name": "Loop 1 → Closed-Loop",+      "start": "2027-09-01",+      "end": "2027-12-31",+      "phase": 3,+      "status": "not-started",+      "description": "Promote Loop 1 from advisory to automatic writebacks.",+      "deps": [+        "loop1"+      ]+    },+    {+      "type": "task",+      "id": "mol10",+      "name": "Molecule-Inventory v1.0",+      "start": "2027-09-01",+      "end": "2028-01-31",+      "phase": 3,+      "status": "not-started",+      "description": "Full stock integration, MSL compliance, BOM cross-check.",+      "deps": [+        "mol01"+      ]+    },+    {+      "type": "milestone",+      "name": "M6: Loop 3 BO running",+      "date": "2028-03-31",+      "phase": 3,+      "description": "Reflow Bayesian optimization operational."+    },+    {+      "type": "milestone",+      "name": "M7: Feeder predictive maint.",+      "date": "2028-02-28",+      "phase": 3,+      "description": "Feeder health monitoring and alerting live."+    },+    {+      "type": "group",+      "label": "Phase 4 — Research (Jul 2028+)",+      "phase": 4+    },+    {+      "type": "task",+      "id": "classif",+      "name": "Custom Defect Classifiers",+      "start": "2028-07-01",+      "end": "2028-12-31",+      "phase": 4,+      "status": "not-started",+      "description": "Train custom vision models on accumulated production data."+    },+    {+      "type": "task",+      "id": "loop8",+      "name": "Loop 8: Transfer Learning",+      "start": "2028-07-01",+      "end": "2028-12-31",+      "phase": 4,+      "status": "not-started",+      "description": "Seed new product recipes from similar existing products."+    },+    {+      "type": "task",+      "id": "loop9",+      "name": "Loop 9: LLM Operator Assist",+      "start": "2028-09-01",+      "end": "2028-12-31",+      "phase": 4,+      "status": "not-started",+      "description": "Plain-English diagnostics and recommendations."+    }   ] }
gantt.html+377−61
@@ -13,11 +13,48 @@ @font-face { font-family: 'Satoshi'; src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2'); font-weight: 500; font-display: swap; } @font-face { font-family: 'JetBrains Mono'; src: url('https://cdn.jsdelivr.net/npm/@fontsource/jetbrains-mono@5/files/jetbrains-mono-latin-400-normal.woff2') format('woff2'); font-weight: 400; font-display: swap; } +/* Adom is a dark system: dark is the base, light is the opt-in override.+   Tokens and contrast ratios come from the adom-ui-design skill.+   --text3 is 2.3:1 and is NEVER text; it is for borders and disabled chrome. */ :root {   --bg: #0d1117; --surface: #161b22; --elevated: #1c2128; --overlay: #21262d;   --border: #30363d; --border-muted: #21262d;-  --text: #e6edf3; --text2: #8b949e; --text3: #484f58;-  --accent: #00b8b0; --accent-bright: #00e6dc; --today: #00b8b0;+  --text: #e6edf3; --text-body: #c9d1d9; --text2: #8b949e; --text3: #484f58;+  --accent: #00b8b1; --accent-hover: #00d4cb; --accent-bright: #00e6dc; --today: #00b8b1;+  --on-accent: #05221f;+  --grid-line: rgba(48,54,61,0.18);+  --row-hover: rgba(255,255,255,0.04);+  --phase-bg-alpha: 0.05;+  /* Phase ramp: teal -> purple -> blue, then a lighter step of each.+     Used when a phase declares no explicit colour. */+  --ph1: #00b8b1; --ph2: #8c6bf7; --ph3: #64abff;+  --ph4: #1fd7d0; --ph5: #bfb1ee; --ph6: #adcdf3;+  --bar-opacity: 0.82;+}+:root[data-theme="light"] {+  --bg: #ffffff; --surface: #f6f8fa; --elevated: #ffffff; --overlay: #ffffff;+  --border: #d0d7de; --border-muted: #d8dee4;+  --text: #1f2328; --text-body: #24292f; --text2: #59636e; --text3: #8c959f;+  --accent: #00857f; --accent-hover: #00a49d; --accent-bright: #00a49d; --today: #00857f;+  --on-accent: #ffffff;+  --grid-line: rgba(31,35,40,0.10);+  --row-hover: rgba(31,35,40,0.04);+  --phase-bg-alpha: 0.10;+  /* Deepened for paper: each clears 3:1 on white while holding its hue. */+  --ph1: #00a49d; --ph2: #8c6bf7; --ph3: #3b95ff;+  --ph4: #00726d; --ph5: #5b32e0; --ph6: #0061ef;+  --bar-opacity: 1;+}+/* Printing is always the light theme, whatever the screen is set to. */+@media print {+  :root { --bg:#ffffff; --surface:#ffffff; --elevated:#ffffff; --overlay:#ffffff;+    --border:#d0d7de; --border-muted:#d8dee4;+    --text:#1f2328; --text-body:#24292f; --text2:#59636e; --text3:#8c959f;+    --accent:#00857f; --today:#00857f; --grid-line:rgba(31,35,40,0.10); --phase-bg-alpha:0.10;+    --ph1:#00a49d; --ph2:#8c6bf7; --ph3:#3b95ff; --ph4:#00726d; --ph5:#5b32e0; --ph6:#0061ef; }+  html, body { overflow: visible !important; height: auto !important; }+  .labels-col, .timeline-col { overflow: visible !important; }+  header .actions, .rev-indicator, .save-indicator { display: none !important; } } *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body { font: 13px/1.4 'Satoshi', -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; background: var(--bg); color: var(--text); overflow: hidden; height: 100%; }@@ -34,6 +71,14 @@ header .subject { display: flex; align-items: center; gap: 8px; min-width: 0; fl header .subject-name { color: var(--text); font-family: 'Familjen Grotesk', sans-serif; font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } header .subject-meta { display: flex; align-items: center; gap: 6px; color: var(--text2); font-family: 'JetBrains Mono', monospace; font-size: 11px; white-space: nowrap; } header .subject-meta .sep { color: var(--text3); }+header .start-ctl { display: flex; align-items: center; gap: 6px; flex-shrink: 0; font-family: 'Satoshi', sans-serif; }+header .start-ctl label { color: var(--text2); font-size: 11px; }+header .start-ctl input[type="month"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text-body); font-family: 'JetBrains Mono', monospace; font-size: 11px; padding: 3px 6px; outline: none; color-scheme: dark; }+:root[data-theme="light"] header .start-ctl input[type="month"] { color-scheme: light; }+header .start-ctl input[type="month"]:focus { border-color: var(--accent); }+header .start-ctl .link-btn { background: none; border: none; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 10px; cursor: pointer; padding: 2px 4px; text-decoration: underline; }+header .start-ctl .link-btn:hover { color: var(--accent); }+header .start-ctl.overridden label, header .start-ctl.overridden input { color: var(--accent); border-color: var(--accent); } header .actions { display: flex; align-items: center; gap: 4px; margin-left: auto; flex-shrink: 0; } header .act-btn { background: none; border: 1px solid var(--border); border-radius: 6px; color: var(--text2); font-family: 'Satoshi', sans-serif; font-size: 11px; padding: 4px 10px; cursor: pointer; transition: background 0.15s, color 0.15s; display: flex; align-items: center; gap: 4px; } header .act-btn:hover { background: rgba(255,255,255,0.04); color: var(--text); }@@ -42,7 +87,7 @@ header .act-btn svg { width: 14px; height: 14px; } /* ── Legend ── */ .legend { display: flex; gap: 4px; padding: 8px 16px; border-bottom: 1px solid var(--border-muted); background: var(--surface); align-items: center; flex-wrap: wrap; flex-shrink: 0; } .legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); font-family: 'Satoshi', sans-serif; padding: 3px 10px; border-radius: 6px; cursor: pointer; transition: opacity 0.15s, background 0.15s; user-select: none; }-.legend-item:hover { background: rgba(255,255,255,0.04); }+.legend-item:hover { background: var(--row-hover); } .legend-item.off { opacity: 0.3; } .legend-item.static { cursor: default; } .legend-item.static:hover { background: none; }@@ -52,19 +97,21 @@ header .act-btn svg { width: 14px; height: 14px; }  /* ── Chart ── */ .chart-wrap { display: flex; flex: 1; overflow: hidden; min-height: 0; }-.labels-col { width: 272px; flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); }+.labels-col { width: clamp(150px, 24vw, 272px); flex-shrink: 0; border-right: 1px solid var(--border); overflow: hidden; background: var(--bg); } .timeline-col { flex: 1; overflow: auto; scrollbar-width: none; } .timeline-col::-webkit-scrollbar { height: 0; width: 0; }+.tailroom { flex-shrink: 0; pointer-events: none; }+@media print { .tailroom { display: none; } } .q-row, .m-row { display: flex; } .q-row { height: 22px; } .m-row { height: 24px; }-.q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }-.m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); border-bottom: 1px solid var(--border); border-right: 1px solid rgba(48,54,61,0.3); }+.q-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border-muted); border-right: 1px solid var(--border-muted); }+.m-cell { display: flex; align-items: center; justify-content: center; height: 100%; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); border-bottom: 1px solid var(--border); border-right: 1px solid var(--border-muted); } .label-h-q { height: 22px; border-bottom: 1px solid var(--border-muted); } .label-h-m { height: 24px; border-bottom: 1px solid var(--border); }  /* ── Rows ── */ .label-row { height: 34px; display: flex; align-items: center; padding: 0 12px; border-bottom: 1px solid rgba(48,54,61,0.15); font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: default; transition: background 0.15s, opacity 0.15s; }-.label-row:hover { background: rgba(255,255,255,0.02); }+.label-row:hover { background: var(--row-hover); } .label-row.group { font-family: 'Familjen Grotesk', sans-serif; font-size: 12px; font-weight: 600; color: var(--text2); background: var(--surface); height: 28px; letter-spacing: 0.02em; } .label-row.ms { color: var(--text2); font-size: 11px; font-style: italic; } .label-row.dim { opacity: 0.15; }@@ -72,24 +119,34 @@ header .act-btn svg { width: 14px; height: 14px; } .tag { display: inline-block; font-family: 'JetBrains Mono', monospace; font-size: 9px; padding: 1px 5px; border-radius: 4px; margin-left: 6px; vertical-align: middle; } .tag-live { background: rgba(63,185,80,0.12); color: #3fb950; } .tag-planned { background: rgba(100,171,255,0.12); color: #64ABFF; }-.tag-not-started { background: rgba(139,148,158,0.10); color: var(--text3); }+.tag-not-started { background: rgba(139,148,158,0.10); color: var(--text2); }  .bar-row { height: 34px; position: relative; border-bottom: 1px solid rgba(48,54,61,0.15); transition: opacity 0.15s; } .bar-row.group { height: 28px; background: var(--surface); } .bar-row.dim { opacity: 0.15; }-.bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: 0.82; cursor: pointer; transition: opacity 0.15s; }+.bar { position: absolute; top: 7px; height: 20px; border-radius: 6px; opacity: var(--bar-opacity, 0.82); cursor: pointer; transition: opacity 0.15s; } .bar:hover { opacity: 1; } .bar.selected { opacity: 1; outline: 2px solid var(--accent); outline-offset: 1px; } .bar-striped { background-image: repeating-linear-gradient(-45deg, transparent, transparent 4px, rgba(0,0,0,0.12) 4px, rgba(0,0,0,0.12) 8px); } .bar .drag-handle { position: absolute; top: 0; width: 8px; height: 100%; cursor: ew-resize; z-index: 2; } .bar .drag-handle.left { left: -2px; border-radius: 6px 0 0 6px; } .bar .drag-handle.right { right: -2px; border-radius: 0 6px 6px 0; }-.bar .drag-handle:hover { background: rgba(255,255,255,0.15); }-.ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; cursor: pointer; transition: box-shadow 0.15s; }-.ms-diamond:hover { box-shadow: 0 0 10px rgba(230,237,243,0.3); }+.bar .drag-handle:hover { background: var(--row-hover); }+.ms-diamond { position: absolute; top: 11px; width: 10px; height: 10px; transform: rotate(45deg) translateX(-50%); background: var(--text); border: 2px solid var(--bg); z-index: 5; pointer-events: none; transition: box-shadow 0.15s; }+/* Transparent hover strip: carries the tooltip in both milestone styles, so the+   full-height line never has to intercept pointer events over the whole chart. */+.ms-hit { position: absolute; top: 0; bottom: 0; width: 16px; transform: translateX(-50%); z-index: 6; cursor: pointer; }+.ms-hit:hover ~ .ms-diamond, .ms-row:hover .ms-diamond { box-shadow: 0 0 10px rgba(230,237,243,0.3); }+/* Line style: a dashed vertical rule spanning the whole chart at the milestone date. */+.ms-line { position: absolute; top: 0; bottom: 0; width: 0; border-left: 1.5px dashed currentColor; opacity: 0.75; z-index: 4; pointer-events: none; }+.ms-line .ms-cap { position: absolute; top: 2px; left: 0; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; line-height: 1; color: inherit; background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid currentColor; white-space: nowrap; }+:root[data-ms="diamond"] .ms-line { display: none; }+:root[data-ms="line"] .ms-diamond { display: none; }+.label-row.ms::before { content: '\25C6\00a0'; }+:root[data-ms="line"] .label-row.ms::before { content: '\00a6\00a0'; } .today-line { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--today); z-index: 8; pointer-events: none; box-shadow: 0 0 6px rgba(0,184,176,0.3); } .today-tag { position: absolute; top: 4px; transform: translateX(-50%); font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--today); background: var(--bg); padding: 1px 4px; border-radius: 3px; border: 1px solid rgba(0,184,176,0.25); pointer-events: none; z-index: 9; white-space: nowrap; }-.phase-bg { position: absolute; top: 0; bottom: 0; opacity: 0.025; pointer-events: none; }+.phase-bg { position: absolute; top: 0; bottom: 0; opacity: var(--phase-bg-alpha); pointer-events: none; } .dep-svg { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 4; overflow: visible; } .dep-svg path { fill: none; stroke: var(--accent); stroke-width: 1.5; opacity: 0.5; transition: opacity 0.15s; } .dep-svg polygon { fill: var(--accent); opacity: 0.5; transition: opacity 0.15s; }@@ -99,7 +156,7 @@ header .act-btn svg { width: 14px; height: 14px; } .tip { display: none; position: fixed; z-index: 99999; background: rgba(22,27,34,0.92); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); border: 1px solid rgba(0,184,177,0.2); border-radius: 10px; padding: 10px 14px; max-width: 340px; box-shadow: 0 12px 40px rgba(0,0,0,0.55); pointer-events: none; font-family: 'Satoshi', sans-serif; font-size: 12px; line-height: 1.5; color: var(--text); } .tip.show { display: block; } .tip .tip-title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 600; font-size: 13px; color: var(--accent); margin-bottom: 4px; }-.tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); margin-bottom: 6px; }+.tip .tip-dates { font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); margin-bottom: 6px; } .tip .tip-body { color: var(--text2); }  /* ── Editor panel (single + batch) ── */@@ -108,7 +165,7 @@ header .act-btn svg { width: 14px; height: 14px; } .editor .drag-bar:active { cursor: grabbing; } .editor .drag-bar::after { content: ''; width: 32px; height: 3px; border-radius: 2px; background: var(--border); } .editor.show { display: block; }-.editor label { display: block; font-size: 11px; color: var(--text3); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; }+.editor label { display: block; font-size: 11px; color: var(--text2); margin-bottom: 4px; font-family: 'JetBrains Mono', monospace; } .editor input[type="date"], .editor input[type="number"] { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px; padding: 5px 8px; width: 130px; outline: none; } .editor input:focus { border-color: var(--accent); } .editor .de-row { display: flex; gap: 10px; margin-bottom: 8px; }@@ -119,17 +176,17 @@ header .act-btn svg { width: 14px; height: 14px; } .de-btn.primary { background: var(--accent); color: var(--bg); } .de-btn.primary:hover { background: var(--accent-bright); } .de-btn.secondary { background: transparent; color: var(--text2); border: 1px solid var(--border); }-.de-btn.secondary:hover { background: rgba(255,255,255,0.04); }+.de-btn.secondary:hover { background: var(--row-hover); } .dep-list { list-style: none; margin: 4px 0 0; } .dep-list li { display: flex; align-items: center; justify-content: space-between; font-size: 11px; color: var(--text2); padding: 3px 0; }-.dep-list .dep-rm { color: var(--text3); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; }+.dep-list .dep-rm { color: var(--text2); cursor: pointer; font-size: 13px; padding: 0 4px; border-radius: 4px; transition: color 0.15s; } .dep-list .dep-rm:hover { color: #f85149; } .batch-title { font-family: 'Familjen Grotesk', sans-serif; font-size: 13px; font-weight: 600; color: var(--accent); margin-bottom: 10px; }-.batch-hint { font-size: 11px; color: var(--text3); margin-bottom: 8px; }+.batch-hint { font-size: 11px; color: var(--text2); margin-bottom: 8px; } .batch-row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .batch-row label { margin: 0; min-width: 60px; } .batch-row input[type="number"] { width: 70px; }-.batch-row span { font-size: 11px; color: var(--text3); }+.batch-row span { font-size: 11px; color: var(--text2); }  /* ── Dropdown menu ── */ .dropdown { position: fixed; z-index: 99997; background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; box-shadow: 0 8px 24px rgba(0,0,0,0.5); display: none; min-width: 180px; }@@ -137,14 +194,16 @@ header .act-btn svg { width: 14px; height: 14px; } .dropdown .dd-item { padding: 6px 14px; font-family: 'Satoshi', sans-serif; font-size: 12px; color: var(--text2); cursor: pointer; transition: background 0.15s; display: flex; align-items: center; gap: 8px; } .dropdown .dd-item:hover { background: rgba(255,255,255,0.04); color: var(--text); } .dropdown .dd-sep { height: 1px; background: var(--border-muted); margin: 4px 0; }-.dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text3); }+.dropdown .dd-label { padding: 4px 14px; font-family: 'JetBrains Mono', monospace; font-size: 9px; color: var(--text2); }  /* ── Toasts ── */ .undo-toast { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); background: var(--elevated); border: 1px solid var(--border); border-radius: 8px; padding: 8px 16px; font-size: 12px; color: var(--text2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 99998; opacity: 0; transition: opacity 0.15s; pointer-events: none; } .undo-toast.show { opacity: 1; }-.undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); }-.save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text3); z-index: 99998; opacity: 0; transition: opacity 0.3s; }+.undo-toast kbd { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 1px 5px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); }+.save-indicator { position: fixed; bottom: 16px; right: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; opacity: 0; transition: opacity 0.3s; } .save-indicator.show { opacity: 1; }+.rev-indicator { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--text2); z-index: 99998; cursor: default; }+.rev-indicator:hover { color: var(--text2); } .sel-count { position: fixed; bottom: 16px; left: 16px; font-family: 'JetBrains Mono', monospace; font-size: 10px; color: var(--accent); z-index: 99998; opacity: 0; transition: opacity 0.15s; } .sel-count.show { opacity: 1; } @@ -172,17 +231,20 @@ input[type="file"] { display: none; } <div class="dropdown" id="importMenu"></div> <div class="undo-toast" id="undoToast">Undid change &middot; <kbd>Ctrl+Z</kbd></div> <div class="save-indicator" id="saveInd">saved</div>+<div class="rev-indicator" id="revInd">rev 0</div> <div class="sel-count" id="selCount"></div> <input type="file" id="importFile" accept=".json,.csv,.xml">  <script> (async function() { -const COL = 66;+const MIN_COL = 34;   // below this the month labels stop being readable, so we scroll instead+let COL = 66;         // month column width, recomputed from the viewport in fitCOL() const $ = id => document.getElementById(id); const labelsEl = $('labels'), timelineEl = $('timeline'), tipEl = $('tip'); const editorEl = $('editor'), undoToast = $('undoToast'), saveInd = $('saveInd'); const selCountEl = $('selCount'), exportMenu = $('exportMenu'), importMenu = $('importMenu');+const revEl = $('revInd'); const importFileEl = $('importFile');  // ── Load data ──@@ -190,12 +252,43 @@ const projData = await fetch('data').then(r => r.json()); const { project, phases, items } = projData; document.title = `${project.title} — adom-gantt`; +// ── View preferences (theme, milestone style, start-date override) ──+const PREF_KEY = 'adom-gantt:prefs';+const prefs = Object.assign(+  { theme: 'dark', msStyle: 'diamond', startOverride: null },+  (() => { try { return JSON.parse(localStorage.getItem(PREF_KEY)) || {}; } catch { return {}; } })()+);+function savePrefs(){ try { localStorage.setItem(PREF_KEY, JSON.stringify(prefs)); } catch {} }+function applyTheme(){ document.documentElement.setAttribute('data-theme', prefs.theme); }+applyTheme();+ // ── Compute date range ──+// dataStart is what the data implies; START is what we draw from, which the user+// can push earlier or later with the start-date control in the header. let minD = '9999-12-31', maxD = '0000-01-01'; items.forEach(r => { if (r.start && r.start < minD) minD = r.start; if (r.end && r.end > maxD) maxD = r.end; if (r.date && r.date < minD) minD = r.date; if (r.date && r.date > maxD) maxD = r.date; }); phases.forEach(p => { const ps = p.start+'-01'; const [ey,em]=p.end.split('-').map(Number); const pe=`${ey}-${String(em).padStart(2,'0')}-28`; if (ps<minD) minD=ps; if (pe>maxD) maxD=pe; }); const [minY,minM] = minD.split('-').map(Number), [maxY,maxM] = maxD.split('-').map(Number);-const START = new Date(minY, minM-1, 1), MONTHS = (maxY-minY)*12+(maxM-minM)+2, W = MONTHS*COL;+const dataStart = new Date(minY, minM-1, 1), dataEnd = new Date(maxY, maxM-1, 1);+let START = new Date(dataStart), MONTHS = 0;+function isoMonth(d){ return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; }+function computeRange(){+  START = prefs.startOverride ? new Date(+prefs.startOverride.split('-')[0], +prefs.startOverride.split('-')[1]-1, 1)+                              : new Date(dataStart);+  MONTHS = Math.max(2, (dataEnd.getFullYear()-START.getFullYear())*12 + (dataEnd.getMonth()-START.getMonth()) + 2);+}+computeRange();+let W = MONTHS*COL;++// Fit the timeline to whatever width the pane actually has. Wide window: months+// stretch so the chart fills it. Narrow window or a very long plan: months stop+// at MIN_COL and the timeline scrolls horizontally instead of squashing.+function fitCOL(){+  // -1 leaves room for the closing grid line, which sits at x=W and is 1px wide+  const avail = (timelineEl.clientWidth || (window.innerWidth - 272)) - 1;+  COL = Math.max(MIN_COL, Math.floor((avail / MONTHS) * 100) / 100);+  W = MONTHS * COL;+}  function mDiff(a,b){return(b.getFullYear()-a.getFullYear())*12+b.getMonth()-a.getMonth();} function toX(ds){const[y,mo,d]=ds.split('-').map(Number);return(mDiff(START,new Date(y,mo-1,d||1))+((d?(d-1)/30:0)))*COL;}@@ -208,13 +301,29 @@ $('hdr').innerHTML = `   <div class="brand"><svg class="logo" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="3" y="4" width="18" height="2" rx="1" opacity="0.5"/><rect x="5" y="9" width="12" height="2" rx="1"/><rect x="3" y="14" width="16" height="2" rx="1" opacity="0.7"/><rect x="7" y="19" width="10" height="2" rx="1" opacity="0.5"/></svg>     <span class="app-name">${project.appName||'adom-gantt'}</span><span class="sep">&middot;</span><span class="app-subtitle">${project.subtitle||'Gantt chart'}</span></div>   <div class="vsep"></div>-  <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">&middot;</span><span>${MONTHS} months</span></span></div>+  <div class="subject"><span class="subject-name">${project.title}</span><span class="subject-meta"><span class="sep">&middot;</span><span id="monthCount">${MONTHS} months</span></span></div>+  <div class="start-ctl" title="First month drawn on the timeline. Clear to follow the data.">+    <label for="startInput">starts</label>+    <input type="month" id="startInput" value="${isoMonth(START)}">+    <button class="link-btn" id="startReset" title="Follow the data (${isoMonth(dataStart)})">reset</button>+  </div>   <div class="actions">+    <button class="act-btn" id="btnMsStyle" title="Milestone style: diamond or dashed line">+      <span id="msStyleIcon"></span><span id="msStyleLabel"></span></button>+    <button class="act-btn" id="btnTheme" title="Toggle light and dark"><span id="themeLabel"></span></button>     <button class="act-btn" id="btnImport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3v12m0 0l-4-4m4 4l4-4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/></svg>Import</button>     <button class="act-btn" id="btnExport"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 21V9m0 0l4 4m-4-4l-4 4M4 7V5a2 2 0 012-2h12a2 2 0 012 2v2"/></svg>Export</button>     <button class="act-btn" id="btnHelp" style="font-family:'Familjen Grotesk',sans-serif;font-size:14px;font-weight:600;padding:4px 9px">?</button>   </div>`; +// A phase with no explicit colour follows the Adom ramp: teal, purple, blue,+// then a second step of each. Returning the CSS var (not a resolved hex) is what+// lets a theme switch recolour every bar without rebuilding the chart.+function phaseColor(i){+  const p = phases[i];+  return (p && p.color) ? p.color : `var(--ph${(i % 6) + 1})`;+}+ // ── State ── const phaseVis = phases.map(()=>true); let msVis=true, depsVis=true;@@ -239,7 +348,7 @@ function undo(){   const sn=undoStack.pop();   if(sn.type==='order'){applyOrder(sn.order);}   else{const r=items.find(d=>d.id===sn.id);if(!r)return;r.start=sn.s;r.end=sn.e;r.deps=sn.deps;updateBar(sn.id);}-  clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave();+  clearTimeout(undoTimer);undoToast.classList.add('show');undoTimer=setTimeout(()=>undoToast.classList.remove('show'),1800);scheduleSave('undo'); } document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){e.preventDefault();undo();}if(e.key==='Escape'){clearSelection();closeEditor();closeMenus();}}); @@ -247,33 +356,94 @@ document.addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='z'){ let saveTimer=null; function getState(){return items.filter(r=>r.id).map(r=>({id:r.id,s:r.start,e:r.end,deps:r.deps||[]}));} function applyState(state){if(!Array.isArray(state))return;state.forEach(sv=>{const r=items.find(d=>d.id===sv.id);if(!r)return;if(sv.s)r.start=sv.s;if(sv.e)r.end=sv.e;if(sv.deps)r.deps=sv.deps;});}-function scheduleSave(){clearTimeout(saveTimer);saveTimer=setTimeout(saveToFile,800);}+// ── Changelog ──+//+// Rather than instrument each edit path by hand (which drifts the moment a new+// one is added), we snapshot the semantic state and diff it at every save. One+// hook, and it cannot disagree with the data. `via` just labels how the change+// was made, for readability in the log.++function nameOf(id){const r=items.find(d=>d.id===id);return r?r.name:id;}+function snapSemantic(){+  const byId={};+  items.forEach(r=>{if(r.id)byId[r.id]={start:r.start,end:r.end,deps:(r.deps||[]).slice()};});+  return {byId,order:items.map(r=>r.id||r.label||r.name)};+}+let lastSnap=snapSemantic();+function diffAndLog(via){+  const cur=snapSemantic(),evs=[];+  Object.keys(cur.byId).forEach(id=>{+    const a=lastSnap.byId[id],b=cur.byId[id];if(!a)return;+    if(a.start!==b.start||a.end!==b.end){+      evs.push({action:'dates',id,name:nameOf(id),+        from:{start:a.start,end:a.end},to:{start:b.start,end:b.end},+        shift:{start:daysBetween(a.start,b.start),end:daysBetween(a.end,b.end)}});+    }+    b.deps.filter(d=>!a.deps.includes(d)).forEach(d=>evs.push({action:'dep.add',id,name:nameOf(id),dep:d,depName:nameOf(d)}));+    a.deps.filter(d=>!b.deps.includes(d)).forEach(d=>evs.push({action:'dep.remove',id,name:nameOf(id),dep:d,depName:nameOf(d)}));+  });+  const before=lastSnap.order,after=cur.order;+  if(JSON.stringify(before)!==JSON.stringify(after)){+    const moved=after.filter((v,i)=>before[i]!==v);+    evs.push({action:'reorder',order:after,moved:moved.slice(0,8)});+  }+  lastSnap=cur;+  if(!evs.length)return;+  fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},+    body:JSON.stringify({actor:'user',events:evs.map(e=>({...e,via}))})})+    .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{});+}+function scheduleSave(via){diffAndLog(via||'edit');clearTimeout(saveTimer);saveTimer=setTimeout(saveToFile,800);} function saveToFile(){fetch('save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(getState())}).then(r=>r.json()).then(()=>{flash(saveInd,'saved');}).catch(()=>{flash(saveInd,'save failed',2500);});} function flash(el,text,ms){el.textContent=text;el.classList.add('show');setTimeout(()=>el.classList.remove('show'),ms||1500);}  // ── Legend ── const legendEl = $('legend');-phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item';el.innerHTML=`<div class="legend-dot" style="background:${ph.color}"></div>${ph.name}`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);});+phases.forEach((ph,i)=>{const el=document.createElement('div');el.className='legend-item';el.innerHTML=`<div class="legend-dot" style="background:${phaseColor(i)}"></div>${ph.name}`;el.addEventListener('click',()=>{phaseVis[i]=!phaseVis[i];el.classList.toggle('off',!phaseVis[i]);applyFilter();});legendEl.appendChild(el);}); const msL=document.createElement('div');msL.className='legend-item';msL.innerHTML='<div class="legend-diamond"></div>Milestone';msL.addEventListener('click',()=>{msVis=!msVis;msL.classList.toggle('off',!msVis);applyFilter();});legendEl.appendChild(msL); const depL=document.createElement('div');depL.className='legend-item';depL.innerHTML='<svg width="14" height="10" style="flex-shrink:0"><path d="M0,5C4,5 10,5 14,5" stroke="var(--accent)" stroke-width="1.5" fill="none" opacity="0.6"/><polygon points="14,5 10,3 10,7" fill="var(--accent)" opacity="0.6"/></svg>Dependencies'; depL.addEventListener('click',()=>{depsVis=!depsVis;depL.classList.toggle('off',!depsVis);depSvg.classList.toggle('hidden',!depsVis);});legendEl.appendChild(depL); legendEl.appendChild(Object.assign(document.createElement('div'),{className:'legend-item static',innerHTML:'<div class="legend-line"></div>Today'}));  // ── Build chart ──+fitCOL();+const phaseBgEls=[], gridEls=[], msLineEls=[]; let todayLineEl=null, todayTagEl=null; labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-q'}));-const qRow=document.createElement('div');qRow.className='q-row';qRow.style.width=W+'px';-let cm=new Date(START);const qs=[];let cq=null;-for(let i=0;i<MONTHS;i++){const ql=`Q${Math.floor(cm.getMonth()/3)+1} ${cm.getFullYear()}`;if(!cq||cq.l!==ql){if(cq)qs.push(cq);cq={l:ql,n:1};}else cq.n++;cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}-if(cq)qs.push(cq);-qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});+const qRow=document.createElement('div');qRow.className='q-row'; labelsEl.appendChild(Object.assign(document.createElement('div'),{className:'label-h-m'}));-const mRow=document.createElement('div');mRow.className='m-row';mRow.style.width=W+'px';+const mRow=document.createElement('div');mRow.className='m-row'; const mNames=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];-cm=new Date(START);for(let i=0;i<MONTHS;i++){const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';c.textContent=mNames[cm.getMonth()];mRow.appendChild(c);cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}+let qs=[];  const bars=document.createElement('div');bars.style.cssText=`position:relative;width:${W}px;`;-phases.forEach((p,i)=>{const bg=document.createElement('div');bg.className='phase-bg';const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);bg.style.left=(mDiff(START,new Date(sy,sm-1,1))*COL)+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-mDiff(START,new Date(sy,sm-1,1))*COL)+'px';bg.style.background=p.color;bars.appendChild(bg);});-for(let i=0;i<=MONTHS;i++){const l=document.createElement('div');l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:rgba(48,54,61,0.18);pointer-events:none;`;bars.appendChild(l);}+// Backgrounds and grid live in their own layers so changing the start date can+// rebuild them without disturbing the rows (and their bound handlers).+const bgLayer=document.createElement('div');bgLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(bgLayer);+const gridLayer=document.createElement('div');gridLayer.style.cssText='position:absolute;inset:0;pointer-events:none;';bars.appendChild(gridLayer);++// Quarter and month header cells, rebuilt whenever START or MONTHS changes.+function buildTimeHeaders(){+  qRow.innerHTML='';mRow.innerHTML='';qs=[];+  let cm=new Date(START),cq=null;+  for(let i=0;i<MONTHS;i++){const ql=`Q${Math.floor(cm.getMonth()/3)+1} ${cm.getFullYear()}`;if(!cq||cq.l!==ql){if(cq)qs.push(cq);cq={l:ql,n:1};}else cq.n++;cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}+  if(cq)qs.push(cq);+  qRow.style.width=W+'px';+  qs.forEach(q=>{const c=document.createElement('div');c.className='q-cell';c.style.width=(q.n*COL)+'px';c.textContent=q.l;qRow.appendChild(c);});+  mRow.style.width=W+'px';+  cm=new Date(START);+  for(let i=0;i<MONTHS;i++){const c=document.createElement('div');c.className='m-cell';c.style.width=COL+'px';c.textContent=mNames[cm.getMonth()];mRow.appendChild(c);cm=new Date(cm.getFullYear(),cm.getMonth()+1,1);}+}+function buildPhaseBands(){+  bgLayer.innerHTML='';phaseBgEls.length=0;+  phases.forEach((p,i)=>{const bg=document.createElement('div');bg.className='phase-bg';const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);+    const l=mDiff(START,new Date(sy,sm-1,1))*COL;bg.style.left=l+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-l)+'px';+    bg.style.background=phaseColor(i);bgLayer.appendChild(bg);phaseBgEls.push(bg);});+}+function buildGridLines(){+  gridLayer.innerHTML='';gridEls.length=0;+  for(let i=0;i<=MONTHS;i++){const l=document.createElement('div');l.style.cssText=`position:absolute;top:0;bottom:0;left:${i*COL}px;width:1px;background:var(--grid-line);pointer-events:none;`;gridLayer.appendChild(l);gridEls.push(l);}+}+buildTimeHeaders();buildPhaseBands();buildGridLines(); depSvg=document.createElementNS('http://www.w3.org/2000/svg','svg');depSvg.classList.add('dep-svg');depSvg.setAttribute('width',W);depSvg.setAttribute('height','1');bars.appendChild(depSvg);  const statusTag={live:'tag-live',planned:'tag-planned','not-started':'tag-not-started'};@@ -282,16 +452,102 @@ const statusLabel={live:'live',planned:'planned','not-started':'not started'}; items.forEach((r,idx)=>{   const lDiv=document.createElement('div'),bDiv=document.createElement('div');   if(r.type==='group'){lDiv.className='label-row group';lDiv.textContent=r.label;bDiv.className='bar-row group';}-  else if(r.type==='milestone'){lDiv.className='label-row ms';lDiv.innerHTML='◆ '+r.name;bDiv.className='bar-row';const x=toX(r.date),d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px';d.dataset.title=r.name;d.dataset.dates=fmt(r.date);d.dataset.body=r.description||'';bDiv.appendChild(d);}-  else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=r.name+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phases[r.phase].color;b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.id=r.id;b.dataset.idx=idx;b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}+  else if(r.type==='milestone'){+    lDiv.className='label-row ms';lDiv.textContent=r.name;bDiv.className='bar-row ms-row';+    const x=toX(r.date);+    const hit=document.createElement('div');hit.className='ms-hit';hit.style.left=x+'px';+    hit.dataset.title=r.name;hit.dataset.dates=fmt(r.date);hit.dataset.body=r.description||'';+    const d=document.createElement('div');d.className='ms-diamond';d.style.left=x+'px';+    bDiv.appendChild(hit);bDiv.appendChild(d);+    // The dashed rule lives on the chart container, not the row, so it can span+    // the full height. Colour follows the milestone's phase.+    const ln=document.createElement('div');ln.className='ms-line';ln.style.left=x+'px';+    ln.style.color=phaseColor(r.phase);+    const cap=document.createElement('div');cap.className='ms-cap';cap.textContent=r.name.replace(/^M(\d+):\s*/,'M$1 ');+    ln.appendChild(cap);msLineEls.push(ln);+    r._msEl=d;r._msHit=hit;r._msLine=ln;+  }+  else{lDiv.className='label-row';lDiv.dataset.id=r.id;const tg=r.status?`<span class="tag ${statusTag[r.status]||''}">${statusLabel[r.status]||r.status}</span>`:'';lDiv.innerHTML=r.name+tg;bDiv.className='bar-row';const x1=toX(r.start),x2=toX(r.end),b=document.createElement('div');b.className='bar'+(r.striped?' bar-striped':'');b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';b.style.background=phaseColor(r.phase);b.dataset.title=r.name;b.dataset.dates=fmt(r.start)+' → '+fmt(r.end);b.dataset.body=r.description||'';b.dataset.id=r.id;b.dataset.idx=idx;b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle left'}));b.appendChild(Object.assign(document.createElement('div'),{className:'drag-handle right'}));bDiv.appendChild(b);barEls[r.id]={bar:b,idx,row:bDiv};}   r._lDiv=lDiv;r._bDiv=bDiv;r._itemIdx=idx;labelsEl.appendChild(lDiv);bars.appendChild(bDiv); }); -const today=new Date(),tx=mDiff(START,today)*COL+(today.getDate()/30)*COL;-if(tx>0&&tx<W){const tl=document.createElement('div');tl.className='today-line';tl.style.left=tx+'px';bars.appendChild(tl);const tt=document.createElement('div');tt.className='today-tag';tt.style.left=tx+'px';tt.textContent='today';bars.appendChild(tt);}+// Appended after the rows so the dashed rules sit above the phase bands.+msLineEls.forEach(ln=>bars.appendChild(ln));+document.documentElement.setAttribute('data-ms', prefs.msStyle);++const today=new Date();+function todayX(){return mDiff(START,today)*COL+(today.getDate()/30)*COL;}+function positionToday(){+  const tx=todayX(), on=tx>0&&tx<W;+  if(todayLineEl){todayLineEl.style.left=tx+'px';todayLineEl.style.display=on?'':'none';}+  if(todayTagEl){todayTagEl.style.left=tx+'px';todayTagEl.style.display=on?'':'none';}+}+{const tl=document.createElement('div');tl.className='today-line';bars.appendChild(tl);todayLineEl=tl;+ const tt=document.createElement('div');tt.className='today-tag';tt.textContent='today';bars.appendChild(tt);todayTagEl=tt;+ positionToday();} timelineEl.appendChild(qRow);timelineEl.appendChild(mRow);timelineEl.appendChild(bars);+const barTail=document.createElement('div');barTail.className='tailroom';timelineEl.appendChild(barTail);+const labelTail=document.createElement('div');labelTail.className='tailroom';labelsEl.appendChild(labelTail); timelineEl.addEventListener('scroll',()=>{labelsEl.scrollTop=timelineEl.scrollTop;}); +// ── Responsive relayout ──+// Everything on the timeline is positioned in px derived from COL, so a resize+// means recomputing COL and repositioning, not rebuilding (handlers stay bound).+function relayout(){+  fitCOL();+  qRow.style.width=W+'px';+  Array.from(qRow.children).forEach((c,i)=>{if(qs[i])c.style.width=(qs[i].n*COL)+'px';});+  mRow.style.width=W+'px';+  Array.from(mRow.children).forEach(c=>{c.style.width=COL+'px';});+  bars.style.width=W+'px';+  phaseBgEls.forEach((bg,i)=>{const p=phases[i];const[sy,sm]=p.start.split('-').map(Number),[ey,em]=p.end.split('-').map(Number);+    const l=mDiff(START,new Date(sy,sm-1,1))*COL;bg.style.left=l+'px';bg.style.width=((mDiff(START,new Date(ey,em-1,1))+1)*COL-l)+'px';});+  gridEls.forEach((l,i)=>{l.style.left=(i*COL)+'px';});+  if(depSvg)depSvg.setAttribute('width',W);+  items.forEach(r=>{+    if(r.type==='milestone'&&r._msEl){+      const x=toX(r.date);+      r._msEl.style.left=x+'px';+      if(r._msHit)r._msHit.style.left=x+'px';+      if(r._msLine)r._msLine.style.left=x+'px';+    }+    else if(r.id&&barEls[r.id]){const b=barEls[r.id].bar,x1=toX(r.start),x2=toX(r.end);b.style.left=x1+'px';b.style.width=Math.max(x2-x1,6)+'px';}+  });+  positionToday();+  sizeTailroom();+  drawDeps();+}++// Room to scroll past the last row, so the bottom of the plan can sit centred in+// the pane instead of being pinned to the bottom edge. Spacers rather than padding+// on the chart container, or the today line and the dashed milestone rules would+// stretch down through the empty tail. Both columns get the same height or they+// drift apart as you scroll.+function sizeTailroom(){+  const pane=timelineEl.clientHeight||0;+  const lastRow=items.length?items[items.length-1]._bDiv:null;+  const rowH=lastRow?lastRow.offsetHeight:34;+  const pad=Math.max(0,Math.round(pane/2-rowH));+  barTail.style.height=pad+'px';+  labelTail.style.height=pad+'px';+}+let rlTimer=null, lastPaneW=timelineEl.clientWidth, lastPaneH=timelineEl.clientHeight;+function scheduleRelayout(){clearTimeout(rlTimer);rlTimer=setTimeout(()=>{+  const w=timelineEl.clientWidth, h=timelineEl.clientHeight;+  if(w===lastPaneW && h===lastPaneH) return;  // guard: ignore observer echo from our own writes+  lastPaneW=w; lastPaneH=h;+  // A width change reflows the whole timeline; a height-only change still has to+  // resize the tailroom, or scrolling past the last row breaks after a vertical drag.+  if(w!==lastRelayoutW){ lastRelayoutW=w; relayout(); }+  else { sizeTailroom(); }+  // One more pass next frame: if the pane was still settling when we measured,+  // the tail would otherwise stay stale until the next resize.+  requestAnimationFrame(()=>{ lastPaneH=timelineEl.clientHeight; sizeTailroom(); });+},80);}+let lastRelayoutW=timelineEl.clientWidth;+window.addEventListener('resize',scheduleRelayout);+if(window.ResizeObserver)new ResizeObserver(scheduleRelayout).observe(timelineEl);+ // ── Filter ── function applyFilter(){items.forEach(r=>{let vis=true;if(r.phase!==undefined&&!phaseVis[r.phase])vis=false;if(r.type==='milestone'&&!msVis)vis=false;r._lDiv.classList.toggle('dim',!vis);r._bDiv.classList.toggle('dim',!vis);});drawDeps();} @@ -393,8 +649,8 @@ function openSingleEditor(id){   if(edY+edH>window.innerHeight) edY=window.innerHeight-edH-8;   if(edY<0) edY=8;   editorEl.style.left=edX+'px';editorEl.style.top=edY+'px';editorEl.classList.add('show');-  $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave();const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});-  $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;updateBar(id);closeEditor();scheduleSave();});+  $('deAddDep').addEventListener('change',function(){if(this.value){snapshot(id);if(!r.deps)r.deps=[];r.deps.push(this.value);renderDepList(id);drawDeps();scheduleSave('editor');const o=this.querySelector(`option[value="${r.deps[r.deps.length-1]}"]`);if(o)o.remove();this.value='';}});+  $('deApply').addEventListener('click',()=>{snapshot(id);const ns=$('deStart').value,ne=$('deEnd').value;if(ns)r.start=ns;if(ne)r.end=ne;updateBar(id);closeEditor();scheduleSave('editor');});   $('deClose').addEventListener('click',closeEditor); } @@ -403,12 +659,12 @@ function renderDepList(id){   const incoming=(r.deps||[]).map(depId=>{const dep=items.find(d=>d.id===depId);return{depId,name:dep?dep.name:depId,dir:'in'};});   const outgoing=items.filter(d=>d.deps&&d.deps.includes(id)).map(d=>({depId:d.id,name:d.name,dir:'out'}));   const all=[...incoming,...outgoing];-  list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text3)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${d.name}</span><span class="dep-rm" data-dep="${d.depId}" data-dir="${d.dir}">×</span></li>`).join('')||'<li style="color:var(--text3)">none</li>';+  list.innerHTML=all.map(d=>`<li><span style="font-family:'JetBrains Mono',monospace;font-size:9px;color:${d.dir==='in'?'var(--accent)':'var(--text2)'};margin-right:4px;min-width:48px">${d.dir==='in'?'← needs':'→ blocks'}</span><span style="flex:1;color:${d.dir==='in'?'var(--text)':'var(--text2)'}">${d.name}</span><span class="dep-rm" data-dep="${d.depId}" data-dir="${d.dir}">×</span></li>`).join('')||'<li style="color:var(--text2)">none</li>';   list.querySelectorAll('.dep-rm').forEach(btn=>{btn.addEventListener('click',()=>{     const depId=btn.dataset.dep, dir=btn.dataset.dir;     if(dir==='in'){snapshot(id);r.deps=r.deps.filter(d=>d!==depId);}     else{const blocker=items.find(d=>d.id===depId);if(blocker){snapshot(depId);blocker.deps=blocker.deps.filter(d=>d!==id);}}-    renderDepList(id);drawDeps();scheduleSave();+    renderDepList(id);drawDeps();scheduleSave('editor');   });}); } @@ -430,7 +686,7 @@ function openBatchEditor(){         <div><label>earliest start</label><input type="date" id="bStart" value="${earliest}"></div>         <div><label>latest end</label><input type="date" id="bEnd" value="${latest}"></div>       </div>-      <div style="font-size:10px;color:var(--text3);margin-top:2px">Changing a date shifts all selected by the same offset</div>+      <div style="font-size:10px;color:var(--text2);margin-top:2px">Changing a date shifts all selected by the same offset</div>     </div>     <div style="border-top:1px solid var(--border-muted);padding-top:8px;margin-bottom:4px">       <div class="batch-row"><label>shift all</label><input type="number" id="bShiftAll" value="0" step="1"> <span>days</span></div>@@ -449,7 +705,7 @@ function openBatchEditor(){     if(totalStartShift===0&&totalEndShift===0){closeEditor();return;}     snapshotMany(ids);     ids.forEach(id=>{const r=items.find(d=>d.id===id);if(!r||!r.start)return;r.start=addDays(r.start,totalStartShift);r.end=addDays(r.end,totalEndShift);updateBar(id);});-    scheduleSave();closeEditor();+    scheduleSave('batch-editor');closeEditor();   });   $('bClose').addEventListener('click',closeEditor); }@@ -558,7 +814,7 @@ document.addEventListener('mousemove',e=>{ document.addEventListener('mouseup',()=>{   gesture=null; -  if(dragState){scheduleSave();dragState=null;document.body.style.cursor='';}+  if(dragState){scheduleSave(dragState.side==='move'?'drag-move':'drag-resize');dragState=null;document.body.style.cursor='';}    if(reorderState&&reorderState.active){     labelDropInd.classList.remove('show');barDropInd.classList.remove('show');@@ -573,7 +829,7 @@ document.addEventListener('mouseup',()=>{       remainingItems.splice(finalInsert,0,...moveItems);       items.length=0;items.push(...remainingItems);       items.forEach((r,i)=>{r._itemIdx=i;labelsEl.appendChild(r._lDiv);bars.appendChild(r._bDiv);if(r.id&&barEls[r.id])barEls[r.id].idx=i;});-      drawDeps();scheduleSave();didDrag=true;+      drawDeps();scheduleSave('drag-reorder');didDrag=true;     }     reorderState=null;   }@@ -642,28 +898,80 @@ $('btnHelp').addEventListener('click',e=>{   closeMenus();const rect=e.currentTarget.getBoundingClientRect();   exportMenu.innerHTML=`     <div class="dd-label">interactions</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click label</span>Select (same as bar)</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click bar</span>Edit dates + deps</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+click</span>Toggle in selection</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Shift+click</span>Range select</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click label</span>Select (same as bar)</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag edge</span>Resize start or end</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag horiz.</span>Move bar dates</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">drag vert.</span>Reorder rows</div>     <div class="dd-sep"></div>     <div class="dd-label">keyboard</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Clear selection</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Ctrl+Z</span>Undo</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">Esc</span>Clear selection</div>     <div class="dd-sep"></div>     <div class="dd-label">legend</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click phase</span>Toggle phase visibility</div>+    <div class="dd-item" style="cursor:default"><span style="color:var(--accent);min-width:90px;font-family:'JetBrains Mono',monospace;font-size:10px">click deps</span>Toggle dependency arrows</div>     <div class="dd-sep"></div>     <div class="dd-label">data</div>-    <div class="dd-item" style="cursor:default;opacity:0.8"><span style="color:var(--text3);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps.</span></div>`;+    <div class="dd-item" style="cursor:default"><span style="color:var(--text-body);font-size:10px">Edits auto-save to sidecar JSON. Ctrl+Z undoes up to 100 steps.</span></div>`;   exportMenu.style.left=Math.min(rect.right-220,window.innerWidth-240)+'px';exportMenu.style.top=(rect.bottom+4)+'px';exportMenu.classList.add('show');   e.stopPropagation(); }); +// ── View controls: theme, milestone style, timeline start ──++function rebuildTimeline(){+  computeRange();+  fitCOL();+  buildTimeHeaders();buildPhaseBands();buildGridLines();+  $('monthCount').textContent=`${MONTHS} months`;+  const ctl=document.querySelector('.start-ctl');+  if(ctl)ctl.classList.toggle('overridden',!!prefs.startOverride);+  $('startInput').value=isoMonth(START);+  relayout();+}++function syncThemeBtn(){ $('themeLabel').textContent = prefs.theme==='dark' ? 'Light' : 'Dark'; }+$('btnTheme').addEventListener('click',()=>{+  prefs.theme = prefs.theme==='dark' ? 'light' : 'dark';+  applyTheme();savePrefs();syncThemeBtn();+});+syncThemeBtn();++function syncMsBtn(){+  const line = prefs.msStyle==='line';+  $('msStyleIcon').textContent = line ? '¦' : '◆';+  $('msStyleLabel').textContent = line ? 'Lines' : 'Diamonds';+}+$('btnMsStyle').addEventListener('click',()=>{+  prefs.msStyle = prefs.msStyle==='line' ? 'diamond' : 'line';+  document.documentElement.setAttribute('data-ms',prefs.msStyle);+  savePrefs();syncMsBtn();+});+syncMsBtn();++$('startInput').addEventListener('change',function(){+  const v=this.value;+  if(!v){ prefs.startOverride=null; }+  else if(v===isoMonth(dataStart)){ prefs.startOverride=null; }+  else { prefs.startOverride=v; }+  savePrefs();+  const was=isoMonth(START);+  rebuildTimeline();+  if(was!==isoMonth(START)){+    fetch('log',{method:'POST',headers:{'Content-Type':'application/json'},+      body:JSON.stringify({actor:'user',events:[{action:'timeline.start',+        from:{start:was},to:{start:isoMonth(START)},via:'start-control'}]})})+      .then(r=>r.json()).then(d=>{if(d&&d.rev)revEl.textContent='rev '+d.rev;}).catch(()=>{});+  }+});+$('startReset').addEventListener('click',()=>{+  if(!prefs.startOverride)return;+  prefs.startOverride=null;savePrefs();rebuildTimeline();+});+ // Close menus on outside click document.addEventListener('click',e=>{if(!e.target.closest('.dropdown')&&!e.target.closest('.act-btn'))closeMenus();}); @@ -684,6 +992,14 @@ document.addEventListener('mouseup',()=>{edDrag=null;});  // ── Init ── try{const r=await fetch('state');if(r.ok){const st=await r.json();if(!st.empty){applyState(st);Object.keys(barEls).forEach(id=>updateBar(id));}}}catch(e){}+// Re-baseline AFTER restoring the sidecar, so replaying saved edits on load is+// not mistaken for a fresh round of user changes.+lastSnap=snapSemantic();+try{const r=await fetch('changelog');if(r.ok){const cl=await r.json();revEl.textContent='rev '+(cl.rev||0);revEl.title=(cl.count||0)+' recorded change(s). GET /changelog?format=md for the readable log.';}}catch(e){}+// A saved start-date override has to reshape the timeline before the first paint.+if(prefs.startOverride) rebuildTimeline();+else { const ctl=document.querySelector('.start-ctl'); if(ctl)ctl.classList.remove('overridden'); }+sizeTailroom(); drawDeps();  })();
package.json+5−3
@@ -2,10 +2,12 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.1.1",-  "description": "Interactive Gantt chart viewer for project timelines. Data-driven, dark-themed, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+  "version": "0.2.0",+  "description": "Interactive Gantt chart viewer for project timelines. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",-  "bin": { "adom-gantt": "server.js" },+  "bin": {+    "adom-gantt": "server.js"+  },   "tags": [     "gantt",     "timeline",
page.json+2−2
@@ -2,8 +2,8 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.1.1",-  "description": "Interactive Gantt chart viewer for project timelines. Data-driven, dark-themed, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",+  "version": "0.2.0",+  "description": "Interactive Gantt chart viewer for project timelines. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {     "adom-gantt": "server.js"
server.js+122
@@ -20,6 +20,7 @@ if (!fs.existsSync(DATA_FILE)) { }  const STATE_FILE = DATA_FILE.replace(/\.json$/, '-state.json');+const LOG_FILE = DATA_FILE.replace(/\.json$/, '-changelog.jsonl'); const APP_DIR = __dirname; const DATA_DIR = path.dirname(DATA_FILE); @@ -56,6 +57,85 @@ function loadState() {   return null; } +// ── Changelog ──+//+// An append-only JSONL record of every edit, so an AI picking this project back+// up can read what a human changed since it last looked and build on top of it+// instead of overwriting it. One JSON object per line, never rewritten.+//+//   { rev, ts, actor, action, id, name, from, to, shift, via }+//+// `rev` is a monotonic counter. An agent notes the rev it has seen, then asks+// for /changelog?since=<rev> next time to get only what changed in between.++function readLog() {+  if (!fs.existsSync(LOG_FILE)) return [];+  return fs.readFileSync(LOG_FILE, 'utf8')+    .split('\n').filter(Boolean)+    .map(l => { try { return JSON.parse(l); } catch { return null; } })+    .filter(Boolean);+}++function currentRev() {+  const log = readLog();+  return log.length ? log[log.length - 1].rev : 0;+}++function appendLog(events, actor) {+  let rev = currentRev();+  const ts = new Date().toISOString();+  const rows = events.map(e => ({+    rev: ++rev,+    ts: e.ts || ts,+    actor: actor || e.actor || 'user',+    ...e,+  }));+  fs.appendFileSync(LOG_FILE, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8');+  return { rev, added: rows.length, rows };+}++function describeEvent(e) {+  const who = e.actor || 'user';+  const via = e.via ? ` (${e.via})` : '';+  const name = e.name ? `"${e.name}"` : e.id || '';+  if (e.action === 'dates') {+    const ds = e.shift ? e.shift.start : 0, de = e.shift ? e.shift.end : 0;+    const move = (ds === de && ds !== 0) ? `moved ${ds > 0 ? '+' : ''}${ds}d`+      : `start ${ds > 0 ? '+' : ''}${ds}d, end ${de > 0 ? '+' : ''}${de}d`;+    return `${who} ${move} on ${name}: ${e.from.start}..${e.from.end} -> ${e.to.start}..${e.to.end}${via}`;+  }+  if (e.action === 'timeline.start') {+    const f = e.from && e.from.start, t = e.to && e.to.start;+    return `${who} set the timeline to start at ${t}${f ? ` (was ${f})` : ''}${via}`;+  }+  if (e.action === 'dep.add') return `${who} made ${name} depend on "${e.depName || e.dep}"${via}`;+  if (e.action === 'dep.remove') return `${who} removed ${name}'s dependency on "${e.depName || e.dep}"${via}`;+  if (e.action === 'reorder') return `${who} reordered rows: ${(e.moved || []).join(', ') || 'row order changed'}${via}`;+  return `${who} ${e.action} ${name}${via}`;+}++function changelogMarkdown(log, since, rev) {+  const title = (() => { try { return loadData().project.title; } catch { return 'project'; } })();+  const lines = [];+  lines.push(`# adom-gantt changelog: ${title}`);+  lines.push('');+  lines.push(`rev ${rev} | ${log.length} event(s)${since ? ` since rev ${since}` : ''} | ${path.basename(DATA_FILE)}`);+  lines.push('');+  if (!log.length) {+    lines.push('No changes recorded' + (since ? ` since rev ${since}.` : ' yet.'));+    return lines.join('\n') + '\n';+  }+  const touched = [...new Set(log.filter(e => e.id).map(e => e.id))];+  if (touched.length) {+    lines.push(`Items touched: ${touched.join(', ')}`);+    lines.push('');+    lines.push('Treat these as deliberate human edits. Build on them; do not reset them.');+    lines.push('');+  }+  log.forEach(e => lines.push(`- [rev ${e.rev}] ${e.ts.slice(0, 19).replace('T', ' ')} ${describeEvent(e)}`));+  return lines.join('\n') + '\n';+}+ function mergedData() {   const data = loadData();   const state = loadState();@@ -673,6 +753,48 @@ const server = http.createServer(async (req, res) => {       return;     } +    // ── Changelog ──++    if (req.method === 'POST' && urlPath === '/log') {+      const body = await readBody(req);+      try {+        const payload = JSON.parse(body);+        const events = Array.isArray(payload) ? payload : (payload.events || []);+        if (!events.length) { jsonResp(res, 200, { ok: true, added: 0, rev: currentRev() }); return; }+        const out = appendLog(events, payload.actor);+        jsonResp(res, 200, { ok: true, added: out.added, rev: out.rev, file: path.basename(LOG_FILE) });+      } catch (e) {+        jsonResp(res, 400, { ok: false, error: e.message });+      }+      return;+    }++    if (urlPath === '/changelog') {+      const since = parseInt(parsed.query.since, 10) || 0;+      const all = readLog();+      const rev = all.length ? all[all.length - 1].rev : 0;+      const sel = since ? all.filter(e => e.rev > since) : all;+      if (parsed.query.format === 'md') {+        res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });+        res.end(changelogMarkdown(sel, since, rev));+      } else {+        jsonResp(res, 200, {+          rev,+          since,+          count: sel.length,+          touched: [...new Set(sel.filter(e => e.id).map(e => e.id))],+          events: sel,+        });+      }+      return;+    }++    if (req.method === 'POST' && urlPath === '/changelog/reset') {+      if (fs.existsSync(LOG_FILE)) fs.unlinkSync(LOG_FILE);+      jsonResp(res, 200, { ok: true, rev: 0 });+      return;+    }+     if (urlPath === '/state') {       if (fs.existsSync(STATE_FILE)) {         res.writeHead(200, { 'Content-Type': 'application/json' });