beadcyte 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +661 -0
  3. package/README.md +386 -0
  4. package/THIRD_PARTY_NOTICES.md +56 -0
  5. package/bin/beadcyte.mjs +60 -0
  6. package/package.json +77 -0
  7. package/src/changelog-cli.mjs +105 -0
  8. package/src/changelog.mjs +196 -0
  9. package/src/cli.mjs +577 -0
  10. package/src/estimator.mjs +314 -0
  11. package/src/format.mjs +22 -0
  12. package/src/history-walk.mjs +170 -0
  13. package/src/index.mjs +5 -0
  14. package/src/mutate.mjs +193 -0
  15. package/src/projects.mjs +120 -0
  16. package/src/provenance.mjs +75 -0
  17. package/src/review-hours.mjs +117 -0
  18. package/src/roster-path.mjs +24 -0
  19. package/src/scheduler.mjs +424 -0
  20. package/src/serve.mjs +411 -0
  21. package/src/server-state.mjs +105 -0
  22. package/src/ship.mjs +178 -0
  23. package/src/stage-waits.mjs +69 -0
  24. package/src/start.mjs +111 -0
  25. package/src/stop.mjs +66 -0
  26. package/src/velocity.mjs +209 -0
  27. package/src/web/App.vue +691 -0
  28. package/src/web/app.css +54 -0
  29. package/src/web/assets/favicon.svg +12 -0
  30. package/src/web/avatar.ts +53 -0
  31. package/src/web/bead-detail.ts +240 -0
  32. package/src/web/changelog-view.ts +41 -0
  33. package/src/web/components/BeadDrawer.vue +1883 -0
  34. package/src/web/components/BeadSubGraph.vue +326 -0
  35. package/src/web/components/BeadSubGraphOverlay.vue +192 -0
  36. package/src/web/components/BeadTooltip.vue +516 -0
  37. package/src/web/components/BeadcyteMark.vue +64 -0
  38. package/src/web/components/BeadsGantt.vue +2125 -0
  39. package/src/web/components/BeadsGrid.vue +468 -0
  40. package/src/web/components/BeadsIncytes.vue +567 -0
  41. package/src/web/components/BeadsMine.vue +325 -0
  42. package/src/web/components/BeadsTable.vue +335 -0
  43. package/src/web/components/ChangelogOverlay.vue +198 -0
  44. package/src/web/components/ContextMenu.vue +386 -0
  45. package/src/web/components/ControlsPanel.vue +476 -0
  46. package/src/web/components/CostTrend.vue +206 -0
  47. package/src/web/components/FilterPopover.vue +245 -0
  48. package/src/web/components/GroupProgress.vue +274 -0
  49. package/src/web/components/LoadMeter.vue +144 -0
  50. package/src/web/components/MineRow.vue +28 -0
  51. package/src/web/components/OptionsMenu.vue +825 -0
  52. package/src/web/components/PriorityChip.vue +105 -0
  53. package/src/web/components/ScoreStrip.vue +131 -0
  54. package/src/web/components/SearchPalette.vue +210 -0
  55. package/src/web/components/ShipTrend.vue +510 -0
  56. package/src/web/components/ShortcutsOverlay.vue +164 -0
  57. package/src/web/components/Term.vue +177 -0
  58. package/src/web/components/Toast.vue +50 -0
  59. package/src/web/components/TriageMeters.vue +426 -0
  60. package/src/web/components/TypeChip.vue +96 -0
  61. package/src/web/components/Walkthrough.vue +209 -0
  62. package/src/web/components/WhatIfPanel.vue +206 -0
  63. package/src/web/components/WipBullets.vue +191 -0
  64. package/src/web/components/filter-option.ts +9 -0
  65. package/src/web/composables/url-codec.ts +136 -0
  66. package/src/web/composables/useBeadTooltip.ts +148 -0
  67. package/src/web/composables/useKeyboard.ts +97 -0
  68. package/src/web/composables/useLiveRefresh.ts +69 -0
  69. package/src/web/composables/useTheme.ts +125 -0
  70. package/src/web/composables/useUrlState.ts +208 -0
  71. package/src/web/controls-scope.ts +83 -0
  72. package/src/web/cost.ts +251 -0
  73. package/src/web/dep-headings.ts +62 -0
  74. package/src/web/economics.ts +440 -0
  75. package/src/web/env.d.ts +85 -0
  76. package/src/web/frontier.ts +208 -0
  77. package/src/web/gantt-viewport.ts +99 -0
  78. package/src/web/highlights.ts +124 -0
  79. package/src/web/index.html +46 -0
  80. package/src/web/insights.ts +107 -0
  81. package/src/web/keybindings.ts +200 -0
  82. package/src/web/load-meter.ts +72 -0
  83. package/src/web/main.ts +20 -0
  84. package/src/web/markdown.ts +14 -0
  85. package/src/web/mine.ts +137 -0
  86. package/src/web/mutations.ts +21 -0
  87. package/src/web/person.ts +102 -0
  88. package/src/web/projects-text.ts +15 -0
  89. package/src/web/projects.ts +188 -0
  90. package/src/web/refresh.ts +47 -0
  91. package/src/web/search.ts +50 -0
  92. package/src/web/shortcuts.ts +113 -0
  93. package/src/web/status-filter.ts +48 -0
  94. package/src/web/store.ts +1378 -0
  95. package/src/web/style-audit.mjs +346 -0
  96. package/src/web/styles-alt.css +111 -0
  97. package/src/web/styles-ported.css +270 -0
  98. package/src/web/subgraph.ts +362 -0
  99. package/src/web/table.ts +201 -0
  100. package/src/web/theme.ts +88 -0
  101. package/src/web/tokens.css +168 -0
  102. package/src/web/triage.ts +914 -0
  103. package/src/web/view-model.ts +717 -0
  104. package/src/web/walkthrough.ts +133 -0
  105. package/src/web/watchlist.ts +47 -0
  106. package/src/web/whatif.ts +291 -0
  107. package/src/web/window.ts +73 -0
  108. package/src/web/wip.ts +83 -0
@@ -0,0 +1,201 @@
1
+ // table.ts — rows and sorting for the table view (bp-67g.44).
2
+ //
3
+ // The table is the surface for scanning and sorting rather than for seeing
4
+ // time: one row per bead with everything the app knows about it, every
5
+ // column sortable. Membership comes from the SAME filtered, windowed view
6
+ // model the grid draws — one definition of "what is in view" — with the
7
+ // Gantt's per-group truncation lifted, because dropping rows is a drawing
8
+ // affordance and a table that silently loses them is worse than a long one.
9
+ //
10
+ // The derived columns are read through the same functions the drawer and the
11
+ // tooltip use (buildBeadDetail for estimate and finish, the triage map for
12
+ // the score), so a number here is the number there.
13
+ //
14
+ // DEFAULT SORT (recorded decision): triage score, descending. The table's
15
+ // job is "what next", and the score is the app's one answer to that; bead id
16
+ // is more predictable but says nothing. Ties break on id ascending so the
17
+ // order is stable from load to load. Pure, so all of it is testable.
18
+
19
+ import type { ViewModel } from "./view-model";
20
+ import type { BeadDetail } from "./bead-detail";
21
+ import type { TriageResult } from "./triage";
22
+
23
+ export type TableSortKey =
24
+ | "id"
25
+ | "title"
26
+ | "type"
27
+ | "priority"
28
+ | "status"
29
+ | "assignee"
30
+ | "labels"
31
+ | "estimate"
32
+ | "finish"
33
+ | "triage"
34
+ | "group";
35
+
36
+ export type SortDir = "asc" | "desc";
37
+
38
+ export interface TableSort {
39
+ key: TableSortKey;
40
+ dir: SortDir;
41
+ }
42
+
43
+ export interface TableRow {
44
+ id: string;
45
+ title: string;
46
+ type: string;
47
+ priority: number;
48
+ status: string;
49
+ assignee: string | null;
50
+ /** The scheduler inferred the assignee; never to be shown as recorded fact. */
51
+ synthetic: boolean;
52
+ labels: string[];
53
+ estimateDays: number | null;
54
+ /** Days from today, for sorting; the date is for showing. */
55
+ finishDay: number | null;
56
+ finishDate: Date | null;
57
+ /** The finish is the scheduling horizon, a bound rather than a projection. */
58
+ beyondHorizon: boolean;
59
+ triage: number | null;
60
+ groupId: string;
61
+ group: string;
62
+ }
63
+
64
+ export interface TableColumn {
65
+ key: TableSortKey;
66
+ label: string;
67
+ /** The direction a fresh click on this column sorts by. */
68
+ natural: SortDir;
69
+ numeric?: boolean;
70
+ }
71
+
72
+ /**
73
+ * In display order. Each column's natural direction is the one a reader
74
+ * wants first: P0 before P4, the soonest finish first, the highest score
75
+ * first, everything textual A to Z.
76
+ */
77
+ export const COLUMNS: readonly TableColumn[] = [
78
+ { key: "id", label: "id", natural: "asc" },
79
+ { key: "title", label: "title", natural: "asc" },
80
+ { key: "type", label: "type", natural: "asc" },
81
+ // Chips, not digits: left-aligned like its cells, though it sorts as a number.
82
+ { key: "priority", label: "pri", natural: "asc" },
83
+ { key: "status", label: "status", natural: "asc" },
84
+ { key: "assignee", label: "assignee", natural: "asc" },
85
+ { key: "labels", label: "labels", natural: "asc" },
86
+ { key: "estimate", label: "estimate", natural: "asc", numeric: true },
87
+ { key: "finish", label: "finish", natural: "asc", numeric: true },
88
+ { key: "triage", label: "triage", natural: "desc", numeric: true },
89
+ { key: "group", label: "group", natural: "asc" },
90
+ ];
91
+
92
+ export const DEFAULT_SORT: TableSort = { key: "triage", dir: "desc" };
93
+
94
+ /** Lifecycle order for the status column; anything unknown sorts after, A to Z. */
95
+ const STATUS_ORDER = ["open", "pinned", "in_progress", "in_review", "blocked", "deferred", "closed", "tombstone"];
96
+
97
+ export interface TableSources {
98
+ detailFor: (id: string) => Pick<BeadDetail, "estimateDays" | "scheduled"> | null;
99
+ triage: ReadonlyMap<string, TriageResult>;
100
+ labelsFor: (id: string) => readonly string[];
101
+ }
102
+
103
+ /** One row per view-model row, in view-model order (sorting comes after). */
104
+ export function buildTableRows(vm: ViewModel, src: TableSources): TableRow[] {
105
+ const groupLabel = new Map(vm.groups.map((g) => [g.id, g.label] as const));
106
+ return vm.rows.map((r) => {
107
+ const d = src.detailFor(r.beadId);
108
+ const s = d?.scheduled ?? null;
109
+ return {
110
+ id: r.beadId,
111
+ title: r.title,
112
+ type: r.issueType,
113
+ priority: r.priority,
114
+ status: r.status,
115
+ assignee: r.assignee,
116
+ synthetic: r.syntheticAssignee,
117
+ labels: [...src.labelsFor(r.beadId)],
118
+ estimateDays: d?.estimateDays ?? null,
119
+ finishDay: s ? s.endDay : null,
120
+ finishDate: s ? s.endDate : null,
121
+ beyondHorizon: s?.beyondHorizon ?? false,
122
+ triage: src.triage.get(r.beadId)?.score ?? null,
123
+ groupId: r.groupId,
124
+ group: r.groupId.startsWith("__") ? "" : groupLabel.get(r.groupId) ?? r.groupId,
125
+ };
126
+ });
127
+ }
128
+
129
+ /** The sort a click produces: the same column flips, a new one starts natural. */
130
+ export function toggleSort(current: TableSort, key: TableSortKey): TableSort {
131
+ if (current.key === key) return { key, dir: current.dir === "asc" ? "desc" : "asc" };
132
+ return { key, dir: COLUMNS.find((c) => c.key === key)?.natural ?? "asc" };
133
+ }
134
+
135
+ type Keyed = { v: number | string | null; row: TableRow };
136
+
137
+ function sortValue(row: TableRow, key: TableSortKey): number | string | null {
138
+ switch (key) {
139
+ case "id":
140
+ return row.id;
141
+ case "title":
142
+ return row.title.toLowerCase();
143
+ case "type":
144
+ return row.type;
145
+ case "priority":
146
+ return row.priority;
147
+ case "status": {
148
+ const i = STATUS_ORDER.indexOf(row.status);
149
+ return i >= 0 ? i : STATUS_ORDER.length;
150
+ }
151
+ case "assignee":
152
+ return row.assignee ? row.assignee.toLowerCase() : null;
153
+ case "labels":
154
+ return row.labels.length ? row.labels.join(",").toLowerCase() : null;
155
+ case "estimate":
156
+ return row.estimateDays;
157
+ case "finish":
158
+ return row.finishDay;
159
+ case "triage":
160
+ return row.triage;
161
+ case "group":
162
+ return row.group || null;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Stable sort on one column. Rows with nothing in the column sort LAST in
168
+ * either direction — an unassigned bead is not "less than" alice on the way
169
+ * up and "more than" her on the way down — and ties break on id ascending
170
+ * so two loads of the same data agree.
171
+ */
172
+ export function sortRows(rows: readonly TableRow[], sort: TableSort): TableRow[] {
173
+ const keyed: Keyed[] = rows.map((row) => ({ v: sortValue(row, sort.key), row }));
174
+ const sign = sort.dir === "asc" ? 1 : -1;
175
+ // Unknown statuses share one rank; fall through to the text so they still order.
176
+ const tieText = sort.key === "status";
177
+ keyed.sort((a, b) => {
178
+ if (a.v === null && b.v === null) return a.row.id.localeCompare(b.row.id);
179
+ if (a.v === null) return 1;
180
+ if (b.v === null) return -1;
181
+ let c = 0;
182
+ if (typeof a.v === "number" && typeof b.v === "number") c = a.v - b.v;
183
+ else c = String(a.v).localeCompare(String(b.v));
184
+ if (c === 0 && tieText) c = a.row.status.localeCompare(b.row.status);
185
+ if (c !== 0) return c * sign;
186
+ return a.row.id.localeCompare(b.row.id);
187
+ });
188
+ return keyed.map((k) => k.row);
189
+ }
190
+
191
+ /** "sorted by triage, highest first" — for the footer. */
192
+ export function describeSort(sort: TableSort): string {
193
+ const col = COLUMNS.find((c) => c.key === sort.key);
194
+ const label = col?.label ?? sort.key;
195
+ // Priority's number runs the other way from its meaning: ascending is P0
196
+ // first, which is the HIGHEST priority. Say the rank, not the number.
197
+ if (sort.key === "priority") return `sorted by ${label}, ${sort.dir === "asc" ? "P0" : "P4"} first`;
198
+ if (sort.key === "finish") return `sorted by ${label}, ${sort.dir === "asc" ? "soonest" : "latest"} first`;
199
+ if (col?.numeric) return `sorted by ${label}, ${sort.dir === "asc" ? "lowest" : "highest"} first`;
200
+ return `sorted by ${label}, ${sort.dir === "asc" ? "A to Z" : "Z to A"}`;
201
+ }
@@ -0,0 +1,88 @@
1
+ // theme.ts — theme/style preference values and their storage format.
2
+ //
3
+ // Pure: no DOM, no localStorage, no Vue. composables/useTheme.ts owns the
4
+ // reactive wiring and the root-element attributes; this owns what counts as
5
+ // a valid preference, so the rules are testable.
6
+
7
+ /** 'system' follows the OS; the others force a mode via [data-theme]. */
8
+ export type ThemeMode = "system" | "light" | "dark";
9
+
10
+ /** Palette family. 'default' is the app's own dark-derived scheme; the rest
11
+ * live in styles-alt.css (ours) and styles-ported.css (ports + High Contrast). */
12
+ export type StyleName =
13
+ | "default"
14
+ | "high-contrast"
15
+ | "catppuccin"
16
+ | "dracula"
17
+ | "github"
18
+ | "gruvbox"
19
+ | "nord"
20
+ | "one"
21
+ | "solarized"
22
+ | "synthwave"
23
+ | "terminal"
24
+ | "tokyonight";
25
+
26
+ export const THEME_MODES: readonly ThemeMode[] = ["system", "light", "dark"];
27
+ /** Picker order: the default, then the accessibility choice, then the rest A–Z. */
28
+ export const STYLE_NAMES: readonly StyleName[] = [
29
+ "default",
30
+ "high-contrast",
31
+ "catppuccin",
32
+ "dracula",
33
+ "github",
34
+ "gruvbox",
35
+ "nord",
36
+ "one",
37
+ "solarized",
38
+ "synthwave",
39
+ "terminal",
40
+ "tokyonight",
41
+ ];
42
+
43
+ /** What the picker prints. Ids stay lowercase single tokens for the URL and storage. */
44
+ export const STYLE_LABELS: Readonly<Record<StyleName, string>> = {
45
+ default: "Default",
46
+ "high-contrast": "High Contrast",
47
+ catppuccin: "Catppuccin",
48
+ dracula: "Dracula",
49
+ github: "GitHub",
50
+ gruvbox: "Gruvbox",
51
+ nord: "Nord",
52
+ one: "One",
53
+ solarized: "Solarized",
54
+ synthwave: "Synthwave",
55
+ terminal: "Terminal",
56
+ tokyonight: "Tokyo Night",
57
+ };
58
+
59
+ export const THEME_STORAGE_KEY = "beadcyte:theme";
60
+ export const STYLE_STORAGE_KEY = "beadcyte:style";
61
+
62
+ export function parseThemeMode(raw: string | null | undefined): ThemeMode {
63
+ return raw && (THEME_MODES as readonly string[]).includes(raw)
64
+ ? (raw as ThemeMode)
65
+ : "system";
66
+ }
67
+
68
+ export function parseStyleName(raw: string | null | undefined): StyleName {
69
+ return raw && (STYLE_NAMES as readonly string[]).includes(raw)
70
+ ? (raw as StyleName)
71
+ : "default";
72
+ }
73
+
74
+ /**
75
+ * What the app is actually showing. 'system' has no answer of its own — it
76
+ * defers to the OS — so the caller supplies what the OS currently prefers.
77
+ */
78
+ export function resolveIsDark(mode: ThemeMode, prefersDark: boolean): boolean {
79
+ if (mode === "dark") return true;
80
+ if (mode === "light") return false;
81
+ return prefersDark;
82
+ }
83
+
84
+ /** Next mode in the cycle, for a single-button toggle. */
85
+ export function nextThemeMode(mode: ThemeMode): ThemeMode {
86
+ const i = THEME_MODES.indexOf(mode);
87
+ return THEME_MODES[(i + 1) % THEME_MODES.length];
88
+ }
@@ -0,0 +1,168 @@
1
+ /* tokens.css — the app's one colour/type/scalar layer.
2
+ *
3
+ * Loaded before app.css and every component's <style>, so anything here can
4
+ * be overridden downstream; loaded before styles-alt.css, so a named style
5
+ * can override it.
6
+ *
7
+ * Three things make this shorter than it looks:
8
+ *
9
+ * 1. `light-dark()` gives one declaration per token instead of the four-way
10
+ * :root / prefers-color-scheme / [data-theme=light] / [data-theme=dark]
11
+ * duplication. `color-scheme` below is what makes it resolve.
12
+ * 2. Colours are authored in OKLCH, so lightness tracks perceived
13
+ * brightness. That matters here because the light-mode values keep each
14
+ * dark hue and only move L — a shift that stays a consistent, comparable
15
+ * step across hues in OKLCH and does not in HSL.
16
+ * 3. Variants are derived once, generically, with relative colour syntax
17
+ * rather than hand-authored per token per theme.
18
+ *
19
+ * The dark values ARE the previous hardcoded palette, converted. Dark mode
20
+ * is meant to look unchanged; any visible difference in dark is a bug.
21
+ */
22
+
23
+ /* Makes light-dark() resolve, and themes native UI (scrollbars, form
24
+ controls) to match. Unset [data-theme] = follow the OS. */
25
+ :root {
26
+ color-scheme: light dark;
27
+ }
28
+ :root[data-theme="light"] {
29
+ color-scheme: light;
30
+ }
31
+ :root[data-theme="dark"] {
32
+ color-scheme: dark;
33
+ }
34
+
35
+ :root,
36
+ /* Lets the options menu preview the default family while another is
37
+ active — same shared-selector trick as styles-alt.css. */
38
+ [data-preview="default"] {
39
+ /* ── surfaces and ink ───────────────────────────────────────────────────
40
+ The light ladder is authored rather than derived: numerically inverting
41
+ the dark ladder lands on muddy mid-greys, where a light UI wants a
42
+ near-white ground carrying the same cool hue at much lower chroma. */
43
+ --c-bg: light-dark(oklch(98.5% 0.004 263.0), oklch(18.3% 0.031 263.4));
44
+ --c-panel: light-dark(oklch(96.3% 0.006 263.0), oklch(21.8% 0.036 262.5));
45
+ --c-panel-2: light-dark(oklch(93.5% 0.009 263.0), oklch(26.7% 0.048 264.7));
46
+ --c-border: light-dark(oklch(88.5% 0.012 263.0), oklch(28.8% 0.050 265.9));
47
+ --c-border-strong: light-dark(oklch(76.0% 0.022 263.0), oklch(38.6% 0.063 264.9));
48
+ --c-text: light-dark(oklch(24.0% 0.020 263.0), oklch(92.8% 0.006 264.5));
49
+ --c-dim: light-dark(oklch(48.0% 0.028 257.0), oklch(71.1% 0.035 256.8));
50
+ --c-dim-bright: light-dark(oklch(36.0% 0.024 253.0), oklch(86.9% 0.020 252.9));
51
+ /* ── data encoding ─────────────────────────────────────────────────────
52
+ These carry MEANING — bar kinds, the priority ramp, blocked/ready-now,
53
+ over-cap. Hue is preserved exactly across themes so green keeps meaning
54
+ shipped and red keeps meaning blocked; only lightness and chroma move,
55
+ because bright-on-dark and deep-on-light are what hold contrast on their
56
+ respective grounds.
57
+
58
+ A named style should think twice before overriding these: restyling the
59
+ chrome is a reskin, but restyling these is relabelling the data. */
60
+ --c-accent: light-dark(oklch(52.0% 0.183 293.5), oklch(70.9% 0.159 293.5));
61
+ --c-epic: light-dark(oklch(50.0% 0.203 305.5), oklch(72.2% 0.177 305.5));
62
+ /* Lightness is placed by search, not by taste (bp-67g.26). Hue stays; the L
63
+ of every colour-only encoding token — the bar kinds, the phases, danger —
64
+ is chosen so each pair clears 6 (aiming for 8) under simulated protan,
65
+ deutan and tritan vision as well as the 0.08 OKLab floor, both as tokens
66
+ and as bars painted over --c-panel. Re-run the placement instead of
67
+ nudging a value by hand: scripts/place-encoding-lightness.mjs. The
68
+ -bright and -stroke variants are text and outline tokens tuned for their
69
+ own contrast and do not track these fills. */
70
+ --c-shipped: light-dark(oklch(40.6% 0.109 151.7), oklch(83.1% 0.182 151.7));
71
+ --c-shipped-stroke: light-dark(oklch(38.0% 0.125 151.3), oklch(44.8% 0.108 151.3));
72
+ --c-shipped-bright: light-dark(oklch(60.0% 0.157 154.4), oklch(87.1% 0.136 154.4));
73
+ --c-in-progress: light-dark(oklch(60.5% 0.165 254.6), oklch(72.4% 0.143 254.6));
74
+ --c-in-progress-bright: light-dark(oklch(58.0% 0.110 251.8), oklch(80.9% 0.096 251.8));
75
+ --c-closed-noship: light-dark(oklch(49.4% 0.047 257.4), oklch(70.9% 0.041 257.4));
76
+ --c-today: light-dark(oklch(64.7% 0.239 25.3), oklch(63.8% 0.208 25.3));
77
+ --c-danger: light-dark(oklch(51.9% 0.191 22.2), oklch(71.0% 0.166 22.2));
78
+ --c-danger-bright: light-dark(oklch(60.0% 0.119 19.6), oklch(80.8% 0.103 19.6));
79
+ --c-warning: light-dark(oklch(62.0% 0.189 84.4), oklch(83.7% 0.164 84.4));
80
+ --c-warning-strong: light-dark(oklch(56.0% 0.183 55.9), oklch(75.8% 0.159 55.9));
81
+ --c-warning-bright: light-dark(oklch(66.0% 0.134 66.3), oklch(83.7% 0.117 66.3));
82
+ /* Bar kinds. These used to alias --c-accent, on the argument that "future"
83
+ isn't told apart from a sibling state by hue. That was wrong: it IS told
84
+ apart from `shipped` by hue, and a style whose accent lands near green
85
+ collapses the two. The terminal style did exactly that — shipped
86
+ oklch(0.8 0.182 151.7) against future oklch(0.78 0.18 145), 6.7 degrees
87
+ apart. So they carry their own hue, fixed like every other encoding
88
+ colour, and a style recolouring its accent can no longer reach them. */
89
+ --c-future: light-dark(oklch(38.8% 0.183 293.5), oklch(63.5% 0.159 293.5));
90
+ --c-backlog: light-dark(oklch(38.8% 0.183 293.5), oklch(63.5% 0.159 293.5));
91
+
92
+ /* Lifecycle phase fills for the economics breakdown. Separate from the
93
+ text-oriented semantics above because a fill and a label want different
94
+ values: light-mode --c-warning sits at L62 for text contrast, which as a
95
+ 10px bar reads as muddy ochre rather than "waiting". Fixed across styles
96
+ like the rest of the encoding — including `other`, which previously
97
+ borrowed --c-border-strong and so drifted with the style while the three
98
+ beside it stayed put. */
99
+ --c-phase-worked: light-dark(oklch(52.6% 0.140 151.7), oklch(78.2% 0.175 151.7));
100
+ --c-phase-review: light-dark(oklch(59.7% 0.150 254.6), oklch(70.6% 0.143 254.6));
101
+ --c-phase-waiting: light-dark(oklch(74.0% 0.155 78.0), oklch(79.1% 0.162 84.4));
102
+ --c-phase-other: light-dark(oklch(74.0% 0.012 263.0), oklch(45.0% 0.063 264.9));
103
+
104
+ /* ── derived once, for whichever style is active ────────────────────────
105
+ Relative colour syntax against the RESOLVED token, so a named style
106
+ that changes --c-accent gets its own hover/dim/ring for free. "Toward
107
+ contrast" means darker on light and lighter on dark. */
108
+ --c-accent-hover: light-dark(
109
+ oklch(from var(--c-accent) calc(l - 0.08) c h),
110
+ oklch(from var(--c-accent) calc(l + 0.09) c h)
111
+ );
112
+ --c-accent-dim: oklch(from var(--c-accent) l c h / 15%);
113
+ --c-focus-ring: var(--c-accent);
114
+
115
+ /* Ink for content sitting ON --c-accent. --c-bg is already the correct
116
+ high-contrast opposite in both directions, so this needs no authoring. */
117
+ --c-accent-fg: var(--c-bg);
118
+
119
+ /* Accent washes, previously hardcoded as rgba() of the accent's literal
120
+ channels — which silently stopped matching the moment a style changed
121
+ the accent. Derived now, so they always track it. */
122
+ --c-accent-wash: oklch(from var(--c-accent) l c h / 8%);
123
+ --c-accent-wash-strong: oklch(from var(--c-accent) l c h / 15%);
124
+ --c-dim-wash: oklch(from var(--c-dim) l c h / 15%);
125
+
126
+ /* A subtle raise off the surface. Inverts direction by theme: lifting
127
+ means lighter on dark and darker on light. */
128
+ --c-lift: light-dark(rgba(0, 0, 0, 0.04), rgba(255, 255, 255, 0.04));
129
+
130
+ /* Scrim dims toward black in both directions, so one plain value. */
131
+ --c-scrim: rgba(0, 0, 0, 0.45);
132
+
133
+ /* Shadows are much weaker on a light ground: the same opacity that reads
134
+ as depth against near-black reads as dirt against near-white. */
135
+ --shadow-panel: light-dark(
136
+ 0 8px 24px rgba(0, 0, 0, 0.12),
137
+ 0 8px 24px rgba(0, 0, 0, 0.4)
138
+ );
139
+ --shadow-pop: light-dark(
140
+ 0 12px 32px rgba(0, 0, 0, 0.16),
141
+ 0 12px 32px rgba(0, 0, 0, 0.5)
142
+ );
143
+ --shadow-drawer: light-dark(
144
+ -20px 0 60px rgba(0, 0, 0, 0.18),
145
+ -20px 0 60px rgba(0, 0, 0, 0.5)
146
+ );
147
+
148
+ /* ── dimming ───────────────────────────────────────────────────────────
149
+ Per-theme, not shared: an opacity is a step toward the background, and
150
+ the same fraction is a different perceptual step against near-white
151
+ than against near-black. Light mode needs to fade less far to read as
152
+ "receded" without vanishing. */
153
+ --o-inferred: light-dark(0.55, 0.62);
154
+ --o-dim-label: light-dark(0.45, 0.35);
155
+ --o-spent-edge: light-dark(0.45, 0.5);
156
+
157
+ /* ── type ──────────────────────────────────────────────────────────────
158
+ Tokenised so a named style can supply its own faces; the default style
159
+ keeps the stack the app already used. */
160
+ --font-body: ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif;
161
+ --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
162
+ monospace;
163
+
164
+ /* ── scalars ───────────────────────────────────────────────────────────── */
165
+ --radius: 4px;
166
+ --radius-sm: 3px;
167
+ --transition: 0.12s ease-out;
168
+ }