From 0bc96adfdd63f39c2b4a541f7106cb11e61840e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:03:54 +0000 Subject: [PATCH 1/9] Settings: ownership as an Owned/Not owned toggle button next to the property name Replaces the "Own " checkbox in Settings and the Businesses gear. The button uses aria-pressed, filled accent when owned, muted outline when not. Closes #31 Co-Authored-By: Claude Opus 5.5 --- docs/decisions/011-owned-properties.md | 6 +++-- src/components/PropertySettings.svelte | 31 ++++++++++++++++++-------- src/components/ui/Button.svelte | 10 ++++++++- src/components/ui/Kit.svelte | 2 ++ 4 files changed, 37 insertions(+), 12 deletions(-) diff --git a/docs/decisions/011-owned-properties.md b/docs/decisions/011-owned-properties.md index 6395eba..be44f3f 100644 --- a/docs/decisions/011-owned-properties.md +++ b/docs/decisions/011-owned-properties.md @@ -15,8 +15,10 @@ no upgrades. Unchecking "owned" keeps the upgrades, so owning it again restores them. Entries for ids the catalog does not list are kept; a saved value that no longer fits its upgrade reads as the default. Logic: `src/lib/properties.ts`. -- **UI:** Settings has a Properties panel with every catalog entry (owned checkbox; upgrades - appear once owned). The Businesses tab shows only owned properties, each panel with a gear +- **UI:** Settings has a Properties panel with every catalog entry: the name as the row label and + a toggle button next to it, "Owned" (accent, filled) or "Not owned" (muted, outlined), with + `aria-pressed` and the name as its accessible label, so the state reads at a glance (issue + #31; it replaced an "Own " checkbox). Upgrades appear once owned. The Businesses tab shows only owned properties, each panel with a gear button that opens the same settings in place; closed, it summarises the bought upgrades. Both components take the catalog as a prop, so `#/kit` previews them with sample entries. diff --git a/src/components/PropertySettings.svelte b/src/components/PropertySettings.svelte index 249015a..564df54 100644 --- a/src/components/PropertySettings.svelte +++ b/src/components/PropertySettings.svelte @@ -1,6 +1,7 @@
- +
+ {property.label} + +
- {#if isOwned(owned, property.id) && property.upgrades.length > 0} + {#if isPropertyOwned && property.upgrades.length > 0}
{#each property.upgrades as upgrade (upgrade.id)} {#if upgrade.kind === 'toggle'} @@ -77,6 +82,14 @@ } .owned { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + justify-content: space-between; + } + + .name { font-family: var(--font-display); letter-spacing: var(--tracking); text-transform: uppercase; diff --git a/src/components/ui/Button.svelte b/src/components/ui/Button.svelte index 7a80876..0327268 100644 --- a/src/components/ui/Button.svelte +++ b/src/components/ui/Button.svelte @@ -3,7 +3,11 @@ import type { HTMLButtonAttributes } from 'svelte/elements' interface Props extends HTMLButtonAttributes { - /** primary: filled green, for the main action. ghost: outlined, for everything else. */ + /** + * primary: filled green, for the main action. ghost: outlined, for everything else. + * A toggle button passes `aria-pressed`: not pressed reads muted, so pair it with `primary` + * when pressed and `ghost` when not. + */ variant?: 'primary' | 'ghost' children: Snippet } @@ -44,6 +48,10 @@ background: var(--accent-hover); } + button[aria-pressed='false'] { + color: var(--muted); + } + button:disabled { cursor: not-allowed; opacity: 0.5; diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index 59dc891..419617b 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -81,6 +81,8 @@ + + {/snippet} From 65a4085447c71bb7bd2895386c0c8d33436d1ae8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:07:44 +0000 Subject: [PATCH 2/9] Business bars: five segments like in game, drag or key to resync Meters show five segments (gta.wiki, Business Supplies; Bunker seen in play). In the resync controls each bar is a slider: drag snaps to whole percent and segment edges, arrows/Page keys/Home/End step it, and a run of slider changes on one bar is one Undo. Typing stays for exact values. Closes #32 Co-Authored-By: Claude Opus 5.5 --- docs/decisions/010-resync.md | 11 +- docs/decisions/README.md | 2 +- src/components/BusinessProduction.svelte | 4 +- src/components/ui/BusinessBars.svelte | 47 +++++-- src/components/ui/Kit.svelte | 1 + src/components/ui/Meter.svelte | 160 ++++++++++++++++++++--- src/data/business-events.ts | 10 ++ src/lib/resync.test.ts | 33 +++++ src/lib/resync.ts | 24 ++++ src/lib/ui.test.ts | 61 ++++++++- src/lib/ui.ts | 64 +++++++++ 11 files changed, 390 insertions(+), 27 deletions(-) diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index 4da5249..d9867e5 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -29,10 +29,19 @@ delivery that arrives while nobody looks still counts. The wait is 10 minutes for every business and runs on real time for now; which clock the game uses is not confirmed. A raid also cancels a delivery on its way. +- **Segmented bars, drag to resync** (issue #32): stock and supplies meters are split into five + segments like in game (`BAR_SEGMENTS` in `src/data/business-events.ts`), collapsed cards too. + In `BusinessBars` each bar is also a slider (`Meter` with `onset`): drag its handle with mouse, + pen or touch, or use the keys (arrows step 1% to the next whole percent, Page Up/Down jump to + the next segment edge, Home/End to 0/100%). A drag rounds to whole percent and snaps to a + segment edge from up to 1.5% away (`dragValue`/`keyValue` in `src/lib/ui.ts`), so 19% and 21% + are reached by key or typing. It commits once, on release, and shows the level under the + pointer as the value while dragging. Typing stays for exact values. - What each event does, and the delivery, are game data in `src/data/business-events.ts` (decision 004). - **Undo takes back the last edit or preset** of that value; in `BusinessBars` one Undo covers - the last change to either bar, events included. It subtracts what the edit changed + the last change to either bar, events included. Dragging and key steps on one bar in a row + are one change (`recordEdit`), so Undo takes back the whole adjustment, not the last 1%. It subtracts what the edit changed rather than restoring the old number, so progress counted since is kept: a cooldown mistyped as 1:30 instead of 2:00 and undone ten minutes later shows 1:50. The undo lives in the component for the session and is not saved. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 0cdd9bb..0ef19c3 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -20,7 +20,7 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [007](007-state-store.md) State store: unreadable data blocks saving like a newer version; import overrides - [008](008-clock-details.md) Clock details: session play time, opens paused (setting), throttled ticks counted, long gaps asked about - [009](009-terminal-design.md) Terminal look: tokens in `app.css`, self-hosted Oswald (OFL), browser-style tabs, base components in `src/components/ui/` -- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since +- [010](010-resync.md) Resync: `EditableValue` for every tracked value, `BusinessBars` with Sold/Raided/Full and supply deliveries, undo keeps progress since, five-segment bars you can drag - [011](011-owned-properties.md) Owned properties and upgrades: catalog in `src/data/properties.ts`, toggle/choice upgrades, Settings panel and Businesses gear - [012](012-business-production.md) Business production: rate per exact upgrade combination in `src/data/production.ts`, continuous on play time, unknown rates do not run - [013](013-marked-estimates.md) Missing game values may be estimated by a stated rule from sourced ones, marked "(est.)" wherever shown diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index 36b2cc7..b6818da 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -1,7 +1,7 @@
@@ -26,19 +90,49 @@ {text}
{/if} -
-
-
+ {#if onset} +
preview(null)} + onlostpointercapture={end} + onkeydown={key} + > + {@render bar()} +
+
+ {:else} +
+ {@render bar()} +
+ {/if}
+{#snippet bar()} +
+
+ {#each { length: Math.max(0, (segments ?? 0) - 1) }, i (i)} +
+ {/each} +
+{/snippet} + diff --git a/src/data/business-events.ts b/src/data/business-events.ts index 3026599..ca7932d 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -44,3 +44,13 @@ export const SUPPLY_PURCHASE = { 'Community wiki (gta.wiki, Business Supplies) also says about ten minutes for MC businesses.', checked: '2026-10-14', } + +/** The stock and supplies bars are split into equal segments, as on the in-game HUD. */ +export const BAR_SEGMENTS = { + count: 5, + source: + 'gta.wiki, Business Supplies: both bars "are broken up into five segments" (MC businesses). ' + + 'Bunker: segments seen in play (issue #32); gta.wiki, Supplies: most resupply missions fill ' + + '"1/5th, or 20 units".', + checked: '2026-10-14', +} diff --git a/src/lib/resync.test.ts b/src/lib/resync.test.ts index f7759f3..a5f20dd 100644 --- a/src/lib/resync.test.ts +++ b/src/lib/resync.test.ts @@ -5,6 +5,7 @@ import { clampValue, formatValue, parseValue, + recordEdit, settleBars, undoBars, undoValue, @@ -150,3 +151,35 @@ describe('buying supplies', () => { expect(undoBars({ before: pending, after: raided }, raided)).toEqual(pending) }) }) + +describe('recordEdit', () => { + it('extends the last edit for a change in the same group', () => { + const first = recordEdit(null, { stock: 40 }, { stock: 45 }, 'stock was 40%', 'stock') + const second = recordEdit(first, { stock: 45 }, { stock: 46 }, 'stock was 45%', 'stock') + expect(second).toEqual({ + before: { stock: 40 }, + after: { stock: 46 }, + label: 'stock was 40%', + group: 'stock', + }) + expect(undoBars(second, { stock: 50 })).toEqual({ stock: 44 }) + }) + + it('starts a new edit for another group or an ungrouped change', () => { + const drag = recordEdit( + null, + { stock: 40, supplies: 10 }, + { stock: 45, supplies: 10 }, + 'a', + 'stock', + ) + const other = recordEdit(drag, drag.after, { stock: 45, supplies: 20 }, 'b', 'supplies') + expect(other.before).toEqual({ stock: 45, supplies: 10 }) + const typed = recordEdit(other, other.after, { stock: 0, supplies: 20 }, 'c') + expect(typed.before).toEqual({ stock: 45, supplies: 20 }) + expect(recordEdit(typed, typed.after, { stock: 1, supplies: 20 }, 'd').before).toEqual({ + stock: 0, + supplies: 20, + }) + }) +}) diff --git a/src/lib/resync.ts b/src/lib/resync.ts index 69baa64..a367fb4 100644 --- a/src/lib/resync.ts +++ b/src/lib/resync.ts @@ -145,3 +145,27 @@ export function undoBars(edit: { before: Bars; after: Bars }, current: Bars): Ba } return next } + +/** The last change to a business's bars, for its one Undo (decision 010). */ +export interface BarsEdit { + before: Bars + after: Bars + label: string + /** Changes with the same group in a row form one edit, e.g. dragging and nudging one bar. */ + group?: string +} + +/** + * Records a change from `before` to `after`. A change in the same `group` as the last edit + * extends it, so Undo takes back the whole run; any other change starts a new edit. + */ +export function recordEdit( + last: BarsEdit | null, + before: Bars, + after: Bars, + label: string, + group?: string, +): BarsEdit { + if (group !== undefined && last?.group === group) return { ...last, after } + return { before: { ...before }, after, label, group } +} diff --git a/src/lib/ui.test.ts b/src/lib/ui.test.ts index b13fc07..2dbedbd 100644 --- a/src/lib/ui.test.ts +++ b/src/lib/ui.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { meterFraction, parseNumberField } from './ui' +import { dragValue, keyValue, meterFraction, parseNumberField } from './ui' describe('meterFraction', () => { it('divides value by max', () => { @@ -42,3 +42,62 @@ describe('parseNumberField', () => { expect(parseNumberField('9.7', { integer: true, max: 9 })).toBe(9) }) }) + +describe('dragValue', () => { + const bar = { max: 100, segments: 5 } + + it('rounds to whole percent', () => { + expect(dragValue(0.374, { max: 100 })).toBe(37) + expect(dragValue(0.376, { max: 100 })).toBe(38) + }) + + it('snaps to a segment edge from up to 1.5% away', () => { + expect(dragValue(0.186, bar)).toBe(20) + expect(dragValue(0.214, bar)).toBe(20) + expect(dragValue(0.986, bar)).toBe(100) + expect(dragValue(0.012, bar)).toBe(0) + }) + + it('does not snap further away or without segments', () => { + expect(dragValue(0.183, bar)).toBe(18) + expect(dragValue(0.217, bar)).toBe(22) + expect(dragValue(0.19, { max: 100 })).toBe(19) + }) + + it('clamps a pointer outside the track', () => { + expect(dragValue(-0.3, bar)).toBe(0) + expect(dragValue(1.4, bar)).toBe(100) + }) +}) + +describe('keyValue', () => { + const bar = { max: 100, segments: 5 } + + it('steps by 1% to the next whole percent', () => { + expect(keyValue(37, 'ArrowRight', bar)).toBe(38) + expect(keyValue(37, 'ArrowUp', bar)).toBe(38) + expect(keyValue(37.4, 'ArrowRight', bar)).toBe(38) + expect(keyValue(37.4, 'ArrowLeft', bar)).toBe(37) + expect(keyValue(37, 'ArrowDown', bar)).toBe(36) + }) + + it('jumps to the next segment edge with Page Up/Down', () => { + expect(keyValue(37, 'PageUp', bar)).toBe(40) + expect(keyValue(40, 'PageUp', bar)).toBe(60) + expect(keyValue(37, 'PageDown', bar)).toBe(20) + expect(keyValue(40, 'PageDown', bar)).toBe(20) + expect(keyValue(37, 'PageUp', { max: 100 })).toBe(40) + expect(keyValue(37, 'PageDown', { max: 100 })).toBe(30) + }) + + it('goes to the ends with Home/End and stays in range', () => { + expect(keyValue(37, 'Home', bar)).toBe(0) + expect(keyValue(37, 'End', bar)).toBe(100) + expect(keyValue(100, 'ArrowRight', bar)).toBe(100) + expect(keyValue(0, 'PageDown', bar)).toBe(0) + }) + + it('ignores other keys', () => { + expect(keyValue(37, 'Enter', bar)).toBeNull() + }) +}) diff --git a/src/lib/ui.ts b/src/lib/ui.ts index 7470a8c..a8ac63b 100644 --- a/src/lib/ui.ts +++ b/src/lib/ui.ts @@ -25,3 +25,67 @@ export function parseNumberField(text: string, bounds: NumberBounds = {}): numbe if (bounds.max !== undefined) value = Math.min(bounds.max, value) return value } + +export interface SliderScale { + max: number + /** Equal segments the bar is split into, e.g. 5 like the in-game business bars. */ + segments?: number +} + +/** Drag snaps to a segment edge from this close, in hundredths of `max`. */ +export const SNAP_TO_EDGE = 1.5 + +/** + * Value under a drag on a slider: `fraction` is how far along the track the pointer is (any + * number; clamped to 0..1). Rounds to whole hundredths of `max` (1% of a business bar) and + * snaps to a segment edge when within `SNAP_TO_EDGE` of it, so the edges are easy to hit. + */ +export function dragValue(fraction: number, scale: SliderScale): number { + const { max, segments } = scale + const raw = meterFraction(fraction, 1) * max + const unit = max / 100 + if (segments && segments > 0) { + const width = max / segments + const edge = Math.round(raw / width) * width + if (Math.abs(raw - edge) <= SNAP_TO_EDGE * unit) return edge + } + return Math.round(raw / unit) * unit +} + +/** + * Value after a slider key: arrows step by a hundredth of `max` to the next whole step, Page + * Up/Down jump to the next segment edge (a tenth of `max` without segments), Home/End go to + * 0/max. Null for any other key. + */ +export function keyValue(value: number, key: string, scale: SliderScale): number | null { + const { max, segments } = scale + const jump = segments && segments > 0 ? max / segments : max / 10 + const next = (step: number, up: boolean) => + up ? (Math.floor(value / step + 1e-9) + 1) * step : (Math.ceil(value / step - 1e-9) - 1) * step + let result: number + switch (key) { + case 'ArrowRight': + case 'ArrowUp': + result = next(max / 100, true) + break + case 'ArrowLeft': + case 'ArrowDown': + result = next(max / 100, false) + break + case 'PageUp': + result = next(jump, true) + break + case 'PageDown': + result = next(jump, false) + break + case 'Home': + result = 0 + break + case 'End': + result = max + break + default: + return null + } + return Math.min(max, Math.max(0, result)) +} From d9c933800a8f61b6c4a0ec44c69b29a49d291296 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 17:08:03 +0000 Subject: [PATCH 3/9] Business bars: segments only in the resync controls, collapsed cards keep a plain bar Co-Authored-By: Claude Opus 5.5 --- docs/decisions/010-resync.md | 5 +++-- src/components/BusinessProduction.svelte | 4 +--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/docs/decisions/010-resync.md b/docs/decisions/010-resync.md index d9867e5..b59375d 100644 --- a/docs/decisions/010-resync.md +++ b/docs/decisions/010-resync.md @@ -29,8 +29,9 @@ delivery that arrives while nobody looks still counts. The wait is 10 minutes for every business and runs on real time for now; which clock the game uses is not confirmed. A raid also cancels a delivery on its way. -- **Segmented bars, drag to resync** (issue #32): stock and supplies meters are split into five - segments like in game (`BAR_SEGMENTS` in `src/data/business-events.ts`), collapsed cards too. +- **Segmented bars, drag to resync** (issue #32): stock and supplies bars are split into five + segments like in game (`BAR_SEGMENTS` in `src/data/business-events.ts`), in the resync + controls only; collapsed cards keep a plain bar to stay clean. In `BusinessBars` each bar is also a slider (`Meter` with `onset`): drag its handle with mouse, pen or touch, or use the keys (arrows step 1% to the next whole percent, Page Up/Down jump to the next segment edge, Home/End to 0/100%). A drag rounds to whole percent and snaps to a diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index b6818da..36b2cc7 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -1,7 +1,7 @@ + + {#if shown.length === 0}

{#if catalog.length === 0} @@ -64,47 +152,50 @@

{#each shown as property (property.id)} {@const production = productions.find((p) => p.propertyId === property.id)} - - {#if open === property.id} - - {:else} -

{summary(property)}

- {/if} - {#if production} - onresync(property.id, bars)} - /> - {/if} - {#snippet footer()} +
+ + {#if open === property.id} + + {:else} +

{summary(property)}

+ {/if} {#if production} + onresync(property.id, bars)} + /> + {/if} + {#snippet footer()} + {#if production} + + {/if} (open = open === property.id ? null : property.id)} > - {/if} - - {/snippet} -
+ + + {/snippet} + +
{/each}
+

{announcement}

{/if}