From 900994abf2a00ee8b5e3d746946dbe3887d24d23 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:08:33 +0000 Subject: [PATCH 1/6] Shared Hint, Note, card footer and empty state; Edit and settings close each other Hint and Note replace the hint and note styles copied per card; MarkOwned the hand-written "Mark what you own in Settings" lines; CardFooter the Edit/gear footers. One mode open per card. The Acid Lab boost's Undo moves to the footer. A temporary switch on Settings tries the cards always editable instead of behind Edit. Co-Authored-By: Claude Opus 5.5 --- src/app.css | 2 + src/components/BusinessProduction.svelte | 54 ++++--------- src/components/Businesses.svelte | 90 ++++++++-------------- src/components/MoneyFrontsCard.svelte | 45 ++++------- src/components/NightclubWarehouse.svelte | 97 ++++++++---------------- src/components/Overview.svelte | 10 +-- src/components/SafeCard.svelte | 33 ++------ src/components/Safes.svelte | 56 ++++---------- src/components/Settings.svelte | 27 ++++++- src/components/Timers.svelte | 32 +++----- src/components/ui/CardFooter.svelte | 47 ++++++++++++ src/components/ui/CardGrid.svelte | 2 +- src/components/ui/Hint.svelte | 27 +++++++ src/components/ui/Kit.svelte | 12 ++- src/components/ui/MarkOwned.svelte | 19 +++++ src/components/ui/Note.svelte | 58 ++++++++++++++ src/lib/cards.svelte.ts | 51 +++++++++++++ src/lib/properties.test.ts | 12 +++ src/lib/properties.ts | 13 ++++ 19 files changed, 388 insertions(+), 299 deletions(-) create mode 100644 src/components/ui/CardFooter.svelte create mode 100644 src/components/ui/Hint.svelte create mode 100644 src/components/ui/MarkOwned.svelte create mode 100644 src/components/ui/Note.svelte create mode 100644 src/lib/cards.svelte.ts diff --git a/src/app.css b/src/app.css index 1f90484..9f82bdf 100644 --- a/src/app.css +++ b/src/app.css @@ -57,6 +57,8 @@ --scanline-gap: 3px; --bar-height: 0.5rem; --max-width: 80rem; + /* Narrowest card column in a tab's card grid (`ui/CardGrid`). */ + --card-width: 20rem; color-scheme: dark; font-family: var(--font-body); diff --git a/src/components/BusinessProduction.svelte b/src/components/BusinessProduction.svelte index fb3f4fb..f9695f9 100644 --- a/src/components/BusinessProduction.svelte +++ b/src/components/BusinessProduction.svelte @@ -24,6 +24,7 @@ import Countdown from './ui/Countdown.svelte' import Fact from './ui/Fact.svelte' import Meter from './ui/Meter.svelte' + import Note from './ui/Note.svelte' interface Props { production: Production @@ -45,8 +46,8 @@ farEstimated?: boolean /** The last production boost, if the business has one (kept after it ends). */ boost?: Boost - /** Called with the new boost, or undefined to take back one started by mistake. */ - onboost?: (boost: Boost | undefined) => void + /** Called with the boost just started; its Undo sits in the card's footer. */ + onboost?: (boost: Boost) => void /** The last Mansion boost, on any business (issue #14). */ mansionBoost?: MansionBoost } @@ -144,13 +145,7 @@ {#if production.boost && running?.boostedMinutesPerUnit !== undefined && !shutDown} {#if boosting} - - {Math.ceil(boost!.unitsLeft)} - {production.unitName} left - - + {Math.ceil(boost!.unitsLeft)} {production.unitName} left {:else if now >= nextBoost} @@ -169,9 +164,9 @@ {/if} {#if shutDown} -

- Shut down. No stock is made and no supplies are used until you start it again. -

+ No stock is made and no supplies are used until you start it again. {:else if ahead} {#if ahead.fullInMs !== null} @@ -189,26 +184,22 @@ /> {/if} {#if rate?.estimate} -

- Estimate. No reliable data for these upgrades. {rate.estimate.how} -

+ No reliable data for these upgrades. {rate.estimate.how} {/if} {#if stacked} -

- Estimate. No source gives the speed with both boosts on: LSOS takes the - boosted speed ×{MANSION_BOOST.speed}. -

+ No source gives the speed with both boosts on: LSOS takes the boosted speed ×{MANSION_BOOST.speed}. {/if} {:else} -

- Not tracked. + {#if rate} There is no reliable data on how fast this business uses supplies with these upgrades. {:else} There is no reliable data for this combination of upgrades. {/if} Stock and supplies stay where you set them. -

+ {/if} diff --git a/src/components/Businesses.svelte b/src/components/Businesses.svelte index d405208..23d5b8b 100644 --- a/src/components/Businesses.svelte +++ b/src/components/Businesses.svelte @@ -19,20 +19,22 @@ import { isShutDown, ownedProperties, - upgradeValue, + upgradeSummary, type OwnedProperties, } from '../lib/properties' import type { GoodsStock } from '../lib/nightclub' import type { Bars } from '../lib/resync' - import { hashForTab } from '../lib/tabs' import type { MansionBoost } from '../lib/timers' + import { CardModes } from '../lib/cards.svelte' import BusinessProduction from './BusinessProduction.svelte' import NightclubWarehouse from './NightclubWarehouse.svelte' import PropertySettings from './PropertySettings.svelte' import Button from './ui/Button.svelte' import CardGrid from './ui/CardGrid.svelte' + import Hint from './ui/Hint.svelte' + import MarkOwned from './ui/MarkOwned.svelte' import Panel from './ui/Panel.svelte' - import SettingsButton from './ui/SettingsButton.svelte' + import CardFooter from './ui/CardFooter.svelte' interface Props { catalog: readonly Property[] @@ -78,33 +80,24 @@ const ownedList = $derived( ownedProperties(catalog, owned).filter((property) => property.card !== false), ) - let open = $state(null) // Cards start collapsed: bars, value and next countdown only (issue #6). - let editing = $state(null) - - /** Short summary of the bought upgrades, e.g. "Equipment, Technician: Cash". */ - function summary(property: Property): string { - const parts = property.upgrades.flatMap((upgrade) => { - const value = upgradeValue(owned, property.id, upgrade) - if (upgrade.kind === 'toggle') return value === true ? [upgrade.label] : [] - if (upgrade.kind === 'count') return [`${upgrade.label}: ${value}`] - if (upgrade.summary === false || value === upgrade.options[0]?.id) return [] - const option = upgrade.options.find((o) => o.id === value) - return option ? [`${upgrade.label}: ${option.label}`] : [] - }) - return parts.length > 0 ? parts.join(', ') : 'No upgrades' - } + const modes = new CardModes() + // The boost a press replaced, so a press by mistake can be taken back from the footer. + let undoBoost = $state<{ id: string; before: Boost | undefined } | null>(null) +{#snippet boostUndo()} + +{/snippet} + {#if ownedList.length === 0} -

- {#if catalog.length === 0} - No businesses are tracked yet. - {:else} - You do not own any business yet. Mark what you own in - Settings. - {/if} -

+ You do not own any business yet. {:else} 0}
- {#if open === property.id} + {#if modes.is(property.id, 'settings')} {:else} -

{summary(property)}

+ {upgradeSummary(owned, property)} {/if} {#if production} onresync(property.id, bars)} bonus={valueBonus(production, owned, property)} farEstimated={farValueEstimated(production, owned)} boost={boosts[property.id]} - onboost={(boost) => onboost(property.id, boost)} + onboost={(boost) => { + undoBoost = { id: property.id, before: boosts[property.id] } + onboost(property.id, boost) + }} {mansionBoost} /> {/if} @@ -147,22 +143,17 @@ {owned} {onchange} stock={nightclubStock} - editing={editing === property.id} + editing={modes.editing(property.id)} onresync={onnightclub} /> {/if} {#snippet footer()} - {#if production || warehouse} - - {/if} - (open = open === property.id ? null : property.id)} + {modes} + editable={!!(production || warehouse)} + undo={undoBoost?.id === property.id ? boostUndo : undefined} /> {/snippet}
@@ -181,21 +172,4 @@ .shell.shut { filter: brightness(0.65); } - - p { - margin: 0; - } - - .empty { - color: var(--muted); - } - - .empty a { - color: var(--accent-text); - } - - .hint { - color: var(--muted); - font-size: var(--text-sm); - } diff --git a/src/components/MoneyFrontsCard.svelte b/src/components/MoneyFrontsCard.svelte index 25a0f96..146e0bf 100644 --- a/src/components/MoneyFrontsCard.svelte +++ b/src/components/MoneyFrontsCard.svelte @@ -25,7 +25,9 @@ import Fact from './ui/Fact.svelte' import EditableBar from './ui/EditableBar.svelte' import EditableValue from './ui/EditableValue.svelte' + import Hint from './ui/Hint.svelte' import Meter from './ui/Meter.svelte' + import Note from './ui/Note.svelte' interface Props { catalog: readonly Property[] @@ -93,16 +95,14 @@ {/each} {#if carWashStopped} -

- Safe stopped. The Car Wash is at 100% heat: the safe gets nothing until you do a - legal job there. -

+ + The Car Wash is at 100% heat: the safe gets nothing until you do a legal job there. + {:else if stopped.length > 0} -

- Front stopped. + {stopped.map((f) => `${f.label} (${money(frontPay(f, owned, catalog))})`).join(', ')} {stopped.length === 1 ? 'is' : 'are'} at 100% heat and pay nothing until a legal job there. -

+ {/if} {#if editing} @@ -128,10 +128,10 @@ {/if} {money(parts.fronts + parts.others)} a day -

+ Fronts {money(parts.fronts)} + your other businesses {money(parts.others)}. A shut-down business adds nothing. -

+ {#if editing} @@ -139,44 +139,25 @@ -

Press when the game says money was added to this safe.

+ Press when the game says money was added to this safe.
{/if} {#if full === null} -

Never full: this safe gets nothing a day.

+ Never full: this safe gets nothing a day. {:else if stopsFirst} -

The safe stops before it is full unless you do a legal job at the Car Wash.

+ The safe stops before it is full unless you do a legal job at the Car Wash. {:else} {/if} -

{FRONTS_ESTIMATE}

+{FRONTS_ESTIMATE} diff --git a/src/components/NightclubWarehouse.svelte b/src/components/NightclubWarehouse.svelte index 56f2a4b..1059031 100644 --- a/src/components/NightclubWarehouse.svelte +++ b/src/components/NightclubWarehouse.svelte @@ -24,7 +24,9 @@ import Digits from './ui/Digits.svelte' import EditableValue from './ui/EditableValue.svelte' import Fact from './ui/Fact.svelte' + import Hint from './ui/Hint.svelte' import Meter from './ui/Meter.svelte' + import Note from './ui/Note.svelte' interface Props { goods: readonly Goods[] @@ -86,7 +88,7 @@ {/each} {:else if shown.length === 0} -

No technician is making goods. Assign one in this card's settings.

+ No technician is making goods. Assign one in this card's settings. {:else} @@ -124,48 +126,43 @@ {formatValue(total, 'money')} {#each fullStuck as id (id)} -

- {label(id)} is full. Sell it: its technician makes nothing until then. -

+ Sell it: its technician makes nothing until then. {/each} {#if suggestion} -
-

- More per hour: - {#each suggestion.moves as move (move.technician)} - {#if full.includes(move.from)} - {label(move.from)} is full. Sell it, or move technician {move.technician} to - {label(move.to)}. - {:else} - Technician {move.technician}: {label(move.to)} - (now {label(move.from)}) - {/if} - {/each} -

-
- +{formatValue(suggestion.gainPerHour, 'money')} / h - -
-
+ + {#each suggestion.moves as move (move.technician)} + {#if full.includes(move.from)} + {label(move.from)} is full. Sell it, or move technician {move.technician} to + {label(move.to)}. + {:else} + Technician {move.technician}: {label(move.to)} + (now {label(move.from)}) + {/if} + {/each} + {#snippet actions()} +
+ +{formatValue(suggestion.gainPerHour, 'money')} / h + +
+ {/snippet} +
{/if} {#if locked.length > 0} -

- Locked. + {locked.map((g) => g.label).join(', ')}: own the business that makes {locked.length === 1 ? 'it' : 'them'}, and keep it running, to start production. -

+ {/if} {#if setup.doubled} -

- Two technicians on one category. The game allows one technician per category, so - only one counts. -

+ The game allows one technician per category, so only one counts. {/if} diff --git a/src/components/Overview.svelte b/src/components/Overview.svelte index f7e081b..5608387 100644 --- a/src/components/Overview.svelte +++ b/src/components/Overview.svelte @@ -13,6 +13,7 @@ import { hashForTab } from '../lib/tabs' import Button from './ui/Button.svelte' import Digits from './ui/Digits.svelte' + import MarkOwned from './ui/MarkOwned.svelte' import Meter from './ui/Meter.svelte' import Select from './ui/Select.svelte' @@ -184,9 +185,7 @@ {#if paused}Paused: play-time countdowns wait for Start{/if} {#if coming.length === 0} -

- Nothing else is tracked. Mark what you own in Settings. -

+ Nothing else is tracked. {:else}
    {#each coming as event (event.id)} @@ -275,11 +274,6 @@ margin: 0; } - .quiet a { - color: var(--accent-text); - text-decoration: underline; - } - /* Money waiting: one strip of readouts, like the in-game business totals. */ .summary { display: grid; diff --git a/src/components/SafeCard.svelte b/src/components/SafeCard.svelte index 4dcecf1..ef611fd 100644 --- a/src/components/SafeCard.svelte +++ b/src/components/SafeCard.svelte @@ -22,7 +22,9 @@ import Fact from './ui/Fact.svelte' import EditableBar from './ui/EditableBar.svelte' import EditableValue from './ui/EditableValue.svelte' + import Hint from './ui/Hint.svelte' import Meter from './ui/Meter.svelte' + import Note from './ui/Note.svelte' interface Props { safe: Safe @@ -75,10 +77,10 @@ {/if} {#if payout < best} -

    - Payout cut. At {popularity}% popularity the safe gets {money(payout)} a day; + + At {popularity}% popularity the safe gets {money(payout)} a day; {maxFrom}% or more pays {money(best)}. -

    + {/if} {/if} @@ -112,43 +114,24 @@ -

    Press when the game says money was added to this safe.

    + Press when the game says money was added to this safe. {/if} {#if full === null} -

    Never full: this safe gets nothing a day.

    + Never full: this safe gets nothing a day. {:else} {/if} {#if safe.estimate} -

    {safe.estimate}

    + {safe.estimate} {/if} diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index 1b55f6a..86ba3e7 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -8,14 +8,14 @@ import { isOwned, type OwnedProperties } from '../lib/properties' import { emptyFronts } from '../lib/fronts' import { emptySafe, type SafesById, type SafeState } from '../lib/safes' - import { hashForTab } from '../lib/tabs' + import { CardModes } from '../lib/cards.svelte' import PropertySettings from './PropertySettings.svelte' import MoneyFrontsCard from './MoneyFrontsCard.svelte' import SafeCard from './SafeCard.svelte' - import Button from './ui/Button.svelte' import CardGrid from './ui/CardGrid.svelte' + import MarkOwned from './ui/MarkOwned.svelte' import Panel from './ui/Panel.svelte' - import SettingsButton from './ui/SettingsButton.svelte' + import CardFooter from './ui/CardFooter.svelte' interface Props { safes: readonly Safe[] @@ -56,23 +56,22 @@ ...(fronts ? [{ id: CAR_WASH, label: 'Money Fronts' }] : []), ...shown.map(({ property }) => ({ id: property.id, label: property.label })), ]) - let open = $state(null) - let editing = $state(null) + const modes = new CardModes() {#if shown.length === 0 && !fronts} -

    + You do not own a property with a safe yet: {[CAR_WASH, ...safes.map((safe) => safe.propertyId)] .map((id) => catalog.find((p) => p.id === id)?.label) - .join(', ')}. Mark what you own in Settings. -

    + .join(', ')}. + {:else} card.id} label={(card) => card.label} {order} {onorder}> {#snippet card({ id }, move, moving)} {@const entry = shown.find(({ property }) => property.id === id)} {#if id === CAR_WASH} - {#if open === CAR_WASH} + {#if modes.is(CAR_WASH, 'settings')} {#each frontProperties as property (property.id)} {/each} @@ -82,26 +81,17 @@ {owned} {onchange} safeState={stored[CAR_WASH] ?? emptyFronts()} - editing={editing === CAR_WASH} + editing={modes.editing(CAR_WASH)} onresync={(state) => onresync(CAR_WASH, state)} /> {#snippet footer()} - - (open = open === CAR_WASH ? null : CAR_WASH)} - /> + {/snippet} {:else if entry} {@const { safe, property } = entry} - {#if open === property.id} + {#if modes.is(property.id, 'settings')} {/if} onresync(property.id, state)} /> {#snippet footer()} - - (open = open === property.id ? null : property.id)} - /> + {/snippet} {/if} {/snippet} {/if} - - diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index 2d9c045..15e2687 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -1,5 +1,6 @@ + +{#if undo}{@render undo()}{/if} + + {#if editable && editTrial.style === 'exclusive'} + + {/if} + {#if settings} + modes.toggle(id, 'settings')} + /> + {/if} + + + diff --git a/src/components/ui/CardGrid.svelte b/src/components/ui/CardGrid.svelte index f164ab1..95fcb95 100644 --- a/src/components/ui/CardGrid.svelte +++ b/src/components/ui/CardGrid.svelte @@ -198,7 +198,7 @@ .grid { display: grid; gap: var(--space-5); - grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--card-width)), 1fr)); } /* Lets the card fill its grid cell. */ diff --git a/src/components/ui/Hint.svelte b/src/components/ui/Hint.svelte new file mode 100644 index 0000000..054b791 --- /dev/null +++ b/src/components/ui/Hint.svelte @@ -0,0 +1,27 @@ + + +

    {@render children()}

    + + diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index 695e54b..cc5ce6c 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -13,7 +13,9 @@ import Countdown from './Countdown.svelte' import EditableValue from './EditableValue.svelte' import Fact from './Fact.svelte' + import Hint from './Hint.svelte' import Meter from './Meter.svelte' + import Note from './Note.svelte' import NumberField from './NumberField.svelte' import Panel from './Panel.svelte' import Select from './Select.svelte' @@ -112,6 +114,14 @@ + + A hint: muted help text. + A fine hint, for footnotes like how an estimate is made. + Warn: something to look at. + Quiet: a plain state. + Good: a suggestion. + + + // What a tab shows while nothing on it is owned (issue #60): what is missing, then a link to + // mark it in Settings. With `property`, Settings opens on that property. + import type { Snippet } from 'svelte' + import { hashForTab } from '../../lib/tabs' + import Hint from './Hint.svelte' + + interface Props { + property?: string + children: Snippet + } + + let { property, children }: Props = $props() + + +{@render children()} Mark what you own in + Settings. diff --git a/src/components/ui/Note.svelte b/src/components/ui/Note.svelte new file mode 100644 index 0000000..d1bde96 --- /dev/null +++ b/src/components/ui/Note.svelte @@ -0,0 +1,58 @@ + + +
    +

    + {#if title}{title}{/if} + {@render children()} +

    + {#if actions}{@render actions()}{/if} +
    + + diff --git a/src/lib/cards.svelte.ts b/src/lib/cards.svelte.ts new file mode 100644 index 0000000..2dcc639 --- /dev/null +++ b/src/lib/cards.svelte.ts @@ -0,0 +1,51 @@ +// Which card on a tab has its edit controls or its settings open (issue #60): one card and one +// mode at a time, so Edit and the gear never stack on the same card. +// +// Trial (issue #60): two ways to edit a card are on offer until one is picked. `exclusive` keeps +// the Edit button; `always` drops it and shows the edit controls on every card. Remove the +// switch and the losing style once picked. + +export type EditStyle = 'exclusive' | 'always' + +const STYLE_KEY = 'lsos-trial-edit-style' + +function savedStyle(): EditStyle { + try { + return localStorage.getItem(STYLE_KEY) === 'always' ? 'always' : 'exclusive' + } catch { + return 'exclusive' + } +} + +export const editTrial = $state({ style: savedStyle() }) + +export function setEditStyle(style: EditStyle) { + editTrial.style = style + try { + localStorage.setItem(STYLE_KEY, style) + } catch { + // Not saved; the choice lasts until reload. + } +} + +export type CardMode = 'edit' | 'settings' + +export class CardModes { + #open = $state<{ id: string; mode: CardMode } | null>(null) + + /** Whether the card shows this mode. */ + is(id: string, mode: CardMode): boolean { + return this.#open?.id === id && this.#open.mode === mode + } + + /** Whether the card shows its edit controls: always on in the `always` style, except while + * its settings are open. */ + editing(id: string): boolean { + return editTrial.style === 'always' ? !this.is(id, 'settings') : this.is(id, 'edit') + } + + /** Opens the mode on the card, closing any other; on again closes it. */ + toggle(id: string, mode: CardMode) { + this.#open = this.is(id, mode) ? null : { id, mode } + } +} diff --git a/src/lib/properties.test.ts b/src/lib/properties.test.ts index 6fc183c..087d2a4 100644 --- a/src/lib/properties.test.ts +++ b/src/lib/properties.test.ts @@ -9,6 +9,7 @@ import { setOwned, setShutDown, setUpgrade, + upgradeSummary, upgradeValue, type OwnedProperties, } from './properties' @@ -91,6 +92,17 @@ describe('upgradeValue', () => { }) }) +describe('upgradeSummary', () => { + it('names the bought upgrades and leaves out the first choice', () => { + const property = sample('lab') + let owned = setOwned({}, 'lab', true) + expect(upgradeSummary(owned, property)).toBe('No upgrades') + owned = setUpgrade(owned, 'lab', 'equipment', true) + owned = setUpgrade(owned, 'lab', 'tech', 'cash') + expect(upgradeSummary(owned, property)).toBe('Equipment, Technician: Cash') + }) +}) + describe('parseOwnedProperties', () => { it('keeps every entry, listed or not', () => { const data = { diff --git a/src/lib/properties.ts b/src/lib/properties.ts index 3e59ea2..b8c6d99 100644 --- a/src/lib/properties.ts +++ b/src/lib/properties.ts @@ -63,6 +63,19 @@ export function upgradeValue( : (upgrade.options[0]?.id ?? '') } +/** Short summary of the bought upgrades, e.g. "Equipment, Technician: Cash". */ +export function upgradeSummary(owned: OwnedProperties, property: Property): string { + const parts = property.upgrades.flatMap((upgrade) => { + const value = upgradeValue(owned, property.id, upgrade) + if (upgrade.kind === 'toggle') return value === true ? [upgrade.label] : [] + if (upgrade.kind === 'count') return [`${upgrade.label}: ${value}`] + if (upgrade.summary === false || value === upgrade.options[0]?.id) return [] + const option = upgrade.options.find((o) => o.id === value) + return option ? [`${upgrade.label}: ${option.label}`] : [] + }) + return parts.length > 0 ? parts.join(', ') : 'No upgrades' +} + /** Marks a property owned or not. Upgrades are kept, so owning it again restores them. */ export function setOwned( owned: OwnedProperties, From 1f501b45c0af24aaad33263df0f2d61db7c8ff8d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:13:27 +0000 Subject: [PATCH 2/6] Settings: fixed-row property list with the picked property's upgrades beside it Rows keep their height and button slots whatever is owned; upgrades of a property not owned show greyed. Links to #/settings/ open that property. Other settings move to a narrow column on wide screens. Decision 029. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + docs/decisions/029-shared-ui.md | 39 ++++ docs/decisions/README.md | 1 + src/App.svelte | 2 +- src/components/PropertyList.svelte | 191 ++++++++++++++-- src/components/PropertySettings.svelte | 114 +--------- src/components/PropertyToggles.svelte | 68 ++++++ src/components/PropertyUpgrades.svelte | 109 ++++++++++ src/components/Settings.svelte | 290 ++++++++++++++----------- 9 files changed, 562 insertions(+), 253 deletions(-) create mode 100644 docs/decisions/029-shared-ui.md create mode 100644 src/components/PropertyToggles.svelte create mode 100644 src/components/PropertyUpgrades.svelte diff --git a/CLAUDE.md b/CLAUDE.md index b79521a..18c60b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,6 +34,7 @@ Svelte 5 + TypeScript + Vite. Public repository. | Overview tab, next events, notifications | `lib/events.ts`, `lib/overview.ts` (dec. 023) | | Hiding cards from the Overview and alerts | `visibleEvents` in `lib/overview.ts` (dec. 028) | | Theme tokens | `src/app.css` (decision 009) | +| Hint, Note, empty state, card footer and modes | `ui/`, `lib/cards.svelte.ts` (decision 029) | | Link previews (Open Graph), preview image | `index.html`, `scripts/og-image.html` | | CI, templates | `.github/` | | Skills: `handoff`, `finish-pr`; agent: `scout` | `.claude/` | diff --git a/docs/decisions/029-shared-ui.md b/docs/decisions/029-shared-ui.md new file mode 100644 index 0000000..bd465c2 --- /dev/null +++ b/docs/decisions/029-shared-ui.md @@ -0,0 +1,39 @@ +# 029: Shared card parts and the Settings list + +2026-10-15, issue #60. Builds on decisions 009, 011 and 026. + +## Decision + +- **One component per repeated pattern** in `src/components/ui/`: + - `Hint`: muted help text in two sizes, normal (`--text-sm`) and `fine` (`--text-xs`, for + footnotes such as how an estimate is made). + - `Note`: a callout with a bar on its left, bold title first. Tones: `warn` (needs a look: + "Estimate.", "Payout cut."), `quiet` (a plain state: "Shut down.") and `good` (a tip, with + `actions` on their own line). + - `MarkOwned`: what a tab shows while nothing on it is owned, with the link to Settings. With + a property, the link opens Settings on that property (`#/settings/`). + - `CardFooter`: the action strip. An Undo for the last press goes on the left; Edit and the + gear go on the right. +- **One mode per card.** `CardModes` (`lib/cards.svelte.ts`) holds which card on a tab has Edit + or its settings open. Opening one closes the other. +- **Every Undo is in the footer**, as on Timers. It is offered only right after a press in that + session, and it puts back what the press replaced. The Acid Lab boost's Undo moved there; it + used to clear the boost. +- **One card width:** `--card-width` (20rem) for every card grid. +- **Settings: list and detail.** Every property is a fixed-height row with its name, a one-line + upgrade summary, and Owned (and Running) buttons in fixed-width slots. Pick a row to set its + upgrades in a sticky panel beside the list. When there is no room beside it, the panel opens + under the picked row instead. A property that is not owned shows its upgrades greyed, so + marking it owned changes nothing in the layout. On wide screens the other settings are a + narrow column on the right. +- **Trial, open:** cards are edited either behind the Edit button or always editable (no Edit + button; the gear is the only mode). The temporary switch on Settings ("Card editing (trial)") + stays until one is picked. The other style and the switch are removed then, and this line is + updated. + +## Alternatives + +- Global CSS classes instead of `Hint` and `Note`: shorter markup, but the tones and the title + would still be written out by hand in every card. +- Settings with collapsible rows, or grouped cards: either one still moves things around as + properties are marked owned or opened. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 5b1706a..2ebc065 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -39,3 +39,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab - [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC - [028](028-hide-from-overview.md) Hide from Overview: per card (event `focus`, timers too), Hide on each item with undo, Settings lists them with Show; still tracked and counted in the totals; showing again brings no alert +- [029](029-shared-ui.md) Shared card parts: `Hint`, `Note` (warn/quiet/good), `MarkOwned`, `CardFooter` (Undo left, Edit and gear right, one mode per card); `--card-width`; Settings as a fixed-row property list with the picked property's upgrades beside it; edit style on trial diff --git a/src/App.svelte b/src/App.svelte index dffdd9a..0a61900 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -139,7 +139,7 @@ {:else if active === 'weekly'} {:else if active === 'settings'} - + {:else}

    Not built yet.

    {/if} diff --git a/src/components/PropertyList.svelte b/src/components/PropertyList.svelte index 2a65870..19dc887 100644 --- a/src/components/PropertyList.svelte +++ b/src/components/PropertyList.svelte @@ -1,44 +1,197 @@ - -
    - {#each catalog as property (property.id)} - - {:else} -

    No properties yet. They appear here as their trackers are built.

    - {/each} +
    +
    + + Trackers show only what you own. Pick one to set its upgrades. +
      + {#each catalog as property (property.id)} + {@const isPicked = property.id === current?.id} +
    • + + { + picked = property.id + onchange(next) + }} + /> +
    • + {#if isPicked} +
    • {@render upgrades(property)}
    • + {/if} + {:else} +
    • No properties yet. They appear here as their trackers are built.
    • + {/each} +
    +
    + + {#if current} +
    + {@render upgrades(current)} +
    + {/if}
    -

    Trackers show only what you own. Upgrades change how fast they count.

    - +
    + +{#snippet upgrades(property: Property)} + {#if property.upgrades.length === 0} + Nothing to set: LSOS tracks it as soon as you mark it owned. + {:else} + + {isOwned(owned, property.id) + ? 'Upgrades change how fast its trackers count.' + : `Mark ${property.label} owned to set its upgrades.`} + + {/if} +{/snippet} diff --git a/src/components/PropertySettings.svelte b/src/components/PropertySettings.svelte index 8f8397f..50c7b69 100644 --- a/src/components/PropertySettings.svelte +++ b/src/components/PropertySettings.svelte @@ -1,18 +1,10 @@
    {property.label} -
    - {#if isPropertyOwned && property.canShutDown} - - {/if} - -
    +
    - {#if isPropertyOwned && property.upgrades.length > 0} + {#if isOwned(owned, property.id) && property.upgrades.length > 0}
    - {#each property.upgrades as upgrade (upgrade.id)} - {#if upgrade.kind === 'toggle'} - - {:else if upgrade.kind === 'count'} -
    - onchange(setUpgrade(owned, property.id, upgrade.id, value))} - /> -
    - {:else} -
    - - + onchange(setUpgrade(owned, property.id, upgrade.id, event.currentTarget.checked))} + /> + {upgrade.label} + + {:else if upgrade.kind === 'count'} +
    + onchange(setUpgrade(owned, property.id, upgrade.id, value))} + /> +
    + {:else} +
    + + - Start tracking play time when LSOS opens - -

    - Off (recommended): LSOS opens paused. Press Start once you have loaded into a GTA Online - session, since that is when the game starts counting. On: loading screens count as play time - too, unless you open LSOS only once you are in. -

    - +
    +
    + + +

    + Off (recommended): LSOS opens paused. Press Start once you have loaded into a GTA Online + session, since that is when the game starts counting. On: loading screens count as play time + too, unless you open LSOS only once you are in. +

    +
    - -

    - When stock is full, supplies run out, a safe is full or a timer is ready: anything on the - Overview. -

    - - - {#if alertError} -

    {alertError}

    - {:else if !notificationsSupported()} -

    This browser cannot show notifications.

    - {/if} -
    - - - (store.state.alerts = { ...alerts, unfocusedOnly: e.currentTarget.checked })} - /> - Only while LSOS is not the focused window, e.g. while you play - -

    Alerts come only while LSOS is open in a tab.

    - + +

    + When stock is full, supplies run out, a safe is full or a timer is ready: anything on the + Overview. +

    + + + {#if alertError} +

    {alertError}

    + {:else if !notificationsSupported()} +

    This browser cannot show notifications.

    + {/if} +
    + + + (store.state.alerts = { ...alerts, unfocusedOnly: e.currentTarget.checked })} + /> + Only while LSOS is not the focused window, e.g. while you play + +

    Alerts come only while LSOS is open in a tab.

    + - - -

    Two ways to edit the cards on Businesses and Safes. Try both, then pick one.

    - - -
    + + +

    + Two ways to edit the cards on Businesses and Safes. Try both, then pick one. +

    + + +
    - -

    - Left out of the Overview and the alerts, but still tracked on their own tabs and counted in - the money waiting. Hide one with Hide on its Overview item. -

    - {#if hidden.length === 0} -

    Nothing is hidden.

    - {:else} - - {/if} -
    + +

    + Left out of the Overview and the alerts, but still tracked on their own tabs and counted in + the money waiting. Hide one with Hide on its Overview item. +

    + {#if hidden.length === 0} +

    Nothing is hidden.

    + {:else} + + {/if} +
    - -

    - Your data lives only in this browser. Export it to keep a copy or to move to another device. -

    -
    - - - -
    - {#if message} -

    {message.text}

    - {/if} -
    + +

    + Your data lives only in this browser. Export it to keep a copy or to move to another device. +

    +
    + + + +
    + {#if message} +

    {message.text}

    + {/if} +
    +
    - -
    - + +
    +
    diff --git a/src/components/NightclubWarehouse.svelte b/src/components/NightclubWarehouse.svelte index 1059031..69dd494 100644 --- a/src/components/NightclubWarehouse.svelte +++ b/src/components/NightclubWarehouse.svelte @@ -1,7 +1,7 @@ -{#if editing} - {#each goods as g (g.id)} - {@const cap = capacity(g, setup.floors)} - onresync(g.id, value)} - > - - - {/each} -{:else if shown.length === 0} +{#if shown.length === 0} No technician is making goods. Assign one in this card's settings. {:else} - + {#each shown as g (g.id)} {@const cap = capacity(g, setup.floors)} {@const ms = fullInMs(g, crates(g.id), setup)} {@const producing = isProducing(g, setup, owned)}
    -
    - {g.label} - {wholeCrates(crates(g.id))} / {cap} · {formatValue( - goodsValue(g, crates(g.id)), - 'money', - )} -
    -
    - - {#if !producing} - Idle - {:else if ms <= 0} - Full - {:else} - - {/if} -
    + onresync(g.id, value)} + > +
    + + {#if !producing} + Idle + {:else if ms <= 0} + Full + {:else} + + {/if} +
    +
    {/each} {/if} @@ -166,20 +149,15 @@ {/if} diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index 86ba3e7..f4930ac 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -8,14 +8,13 @@ import { isOwned, type OwnedProperties } from '../lib/properties' import { emptyFronts } from '../lib/fronts' import { emptySafe, type SafesById, type SafeState } from '../lib/safes' - import { CardModes } from '../lib/cards.svelte' import PropertySettings from './PropertySettings.svelte' import MoneyFrontsCard from './MoneyFrontsCard.svelte' import SafeCard from './SafeCard.svelte' import CardGrid from './ui/CardGrid.svelte' import MarkOwned from './ui/MarkOwned.svelte' import Panel from './ui/Panel.svelte' - import CardFooter from './ui/CardFooter.svelte' + import SettingsButton from './ui/SettingsButton.svelte' interface Props { safes: readonly Safe[] @@ -56,9 +55,14 @@ ...(fronts ? [{ id: CAR_WASH, label: 'Money Fronts' }] : []), ...shown.map(({ property }) => ({ id: property.id, label: property.label })), ]) - const modes = new CardModes() + // The card whose settings are open, one at a time. + let open = $state(null) +{#snippet gear(id: string, label: string)} + (open = open === id ? null : id)} /> +{/snippet} + {#if shown.length === 0 && !fronts} You do not own a property with a safe yet: {[CAR_WASH, ...safes.map((safe) => safe.propertyId)] @@ -71,7 +75,8 @@ {@const entry = shown.find(({ property }) => property.id === id)} {#if id === CAR_WASH} - {#if modes.is(CAR_WASH, 'settings')} + {#snippet actions()}{@render gear(CAR_WASH, 'Money Fronts')}{/snippet} + {#if open === CAR_WASH} {#each frontProperties as property (property.id)} {/each} @@ -81,17 +86,14 @@ {owned} {onchange} safeState={stored[CAR_WASH] ?? emptyFronts()} - editing={modes.editing(CAR_WASH)} onresync={(state) => onresync(CAR_WASH, state)} /> - {#snippet footer()} - - {/snippet} {:else if entry} {@const { safe, property } = entry} - {#if modes.is(property.id, 'settings')} + {#snippet actions()}{@render gear(property.id, property.label)}{/snippet} + {#if open === property.id} {/if} onresync(property.id, state)} /> - {#snippet footer()} - - {/snippet} {/if} {/snippet} diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index d9891c3..a7a86ed 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -1,12 +1,12 @@ - -{#snippet wheelUndo()} - -{/snippet} - -{#snippet mansionUndo()} - -{/snippet} - card.id} label={(card) => card.label} {order} {onorder}> {#snippet card({ id }, move, moving)} {#if id === 'lucky-wheel'} - + {#if now < spinAt} + {:else} @@ -122,13 +100,7 @@ One spin per 24 real hours, also while LSOS is closed. {:else} - + {#if !mansionOwned} The production boost from the Mansion's AI assistant, once you own a Mansion.{label(last!.propertyId)} + void - /** False for a business that cannot be raided: no "Raided" button. */ - raids?: boolean + /** Bar labels, e.g. with "(est.)" when the rates are estimated (decision 013). */ + stockLabel?: string + suppliesLabel?: string + /** Muted text after the stock label, e.g. "40 / 100 units". */ + stockDetail?: string } - let { bars: stored, now, onresync, raids = true }: Props = $props() + let { + bars: stored, + now, + onresync, + stockLabel = 'Stock', + suppliesLabel = 'Supplies', + stockDetail, + }: Props = $props() // The bars as of now, with an arrived delivery filled in. Every change starts from these. const bars = $derived(settleBars(stored, now, SUPPLY_PURCHASE.fillsTo)) const dueAt = $derived(bars.suppliesDueAt) - const events = $derived( - Object.entries(BUSINESS_EVENTS) - .filter(([id]) => raids || id !== 'raided') - .map(([, event]) => event) - .filter((event) => bars.supplies !== undefined || !event.empties.includes('supplies')), - ) + const sold = BUSINESS_EVENTS.sold - // One undo for the whole business: the last edit, Full or event, whichever bars it changed. + // One undo for the whole business: the last edit or event, whichever bars it changed. // Dragging and nudging one bar with the keys in a row count as one edit. let last = $state(null) @@ -66,7 +71,8 @@
    {#if bars.supplies !== undefined}
    - {#if bars.supplies !== undefined} {/if} - {#each events as event (event.label)} - - {/each} + {#if last} {/if} diff --git a/src/components/ui/Button.svelte b/src/components/ui/Button.svelte index 0327268..f3db074 100644 --- a/src/components/ui/Button.svelte +++ b/src/components/ui/Button.svelte @@ -9,13 +9,15 @@ * when pressed and `ghost` when not. */ variant?: 'primary' | 'ghost' + /** Smaller, for a row of several actions or a title bar. */ + small?: boolean children: Snippet } - let { variant = 'ghost', type = 'button', children, ...rest }: Props = $props() + let { variant = 'ghost', small = false, type = 'button', children, ...rest }: Props = $props() - + diff --git a/src/components/ui/EditableBar.svelte b/src/components/ui/EditableBar.svelte index f6e107c..c155097 100644 --- a/src/components/ui/EditableBar.svelte +++ b/src/components/ui/EditableBar.svelte @@ -12,6 +12,8 @@ kind?: 'stock' | 'supplies' | 'heat' segments?: number presets?: { label: string; value: number; title?: string }[] + /** Muted text after the label, e.g. "40 / 100 units". */ + detail?: string undoable?: boolean /** Called with a typed value or a preset. */ onresync: (value: number) => void @@ -27,6 +29,7 @@ kind, segments, presets, + detail, undoable, onresync, onset = onresync, @@ -43,6 +46,7 @@ min={0} max={100} {presets} + {detail} {undoable} {onresync} > diff --git a/src/components/ui/EditableValue.svelte b/src/components/ui/EditableValue.svelte index 2c19afc..41eab88 100644 --- a/src/components/ui/EditableValue.svelte +++ b/src/components/ui/EditableValue.svelte @@ -27,6 +27,10 @@ max?: number /** One-click values, e.g. "Sold" setting stock to 0. The tracker decides what each one means. */ presets?: Preset[] + /** Presets as small buttons next to the value instead of a row of their own. */ + inlinePresets?: boolean + /** Muted text after the label, e.g. "40 / 100 units". */ + detail?: string /** Show an Undo button for the last edit. Off when the parent keeps one undo for several values. */ undoable?: boolean /** Shown under the value, e.g. a bare Meter. */ @@ -42,6 +46,8 @@ min, max, presets = [], + inlinePresets = false, + detail, undoable = true, children, onresync, @@ -106,7 +112,10 @@
    - {label} + {label}{#if detail}{detail}{/if} {#if editing}
    Cancel
    {:else} + {#if inlinePresets} + + {#each presets as preset (preset.label)} + + {/each} + + {/if} {/each} {#if last} @@ -183,6 +201,22 @@ text-transform: uppercase; } + .detail { + color: var(--muted); + font-family: var(--font-body); + font-size: var(--text-xs); + letter-spacing: normal; + margin-left: var(--space-2); + text-transform: none; + } + + /* The presets sit by the value, the label keeps the left. */ + .inline { + display: flex; + gap: var(--space-1); + margin-left: auto; + } + .value { background: none; border: 1px dashed var(--line); diff --git a/src/components/ui/GearIcon.svelte b/src/components/ui/GearIcon.svelte new file mode 100644 index 0000000..efab234 --- /dev/null +++ b/src/components/ui/GearIcon.svelte @@ -0,0 +1,14 @@ + + + + diff --git a/src/components/ui/Kit.svelte b/src/components/ui/Kit.svelte index cc5ce6c..103360a 100644 --- a/src/components/ui/Kit.svelte +++ b/src/components/ui/Kit.svelte @@ -19,6 +19,7 @@ import NumberField from './NumberField.svelte' import Panel from './Panel.svelte' import Select from './Select.svelte' + import SettingsButton from './SettingsButton.svelte' let amount = $state(40) let picked = $state('a') @@ -84,13 +85,18 @@ - {#snippet footer()} + + + + {#snippet actions()} {}} />{/snippet} +
    - {/snippet} + +
    @@ -185,6 +191,12 @@ grid-template-columns: repeat(auto-fill, minmax(min(100%, var(--card-width)), 1fr)); } + .row { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + } + h2 { font-size: var(--text-lg); margin: var(--space-6) 0 var(--space-4); diff --git a/src/components/ui/Meter.svelte b/src/components/ui/Meter.svelte index 20754c5..737cbaa 100644 --- a/src/components/ui/Meter.svelte +++ b/src/components/ui/Meter.svelte @@ -254,4 +254,19 @@ transform: translate(-50%, -50%); width: 0.5rem; } + + /* Every card's bars can be dragged (issue #60): the handle shows only over the card, or while + the bar has focus, so a tab of cards reads as plain bars. Touch screens always show it. */ + @media (hover: hover) { + .handle { + opacity: 0; + transition: opacity 0.12s; + } + + :global(.panel:hover) .handle, + .slider:focus-visible .handle, + .slider:active .handle { + opacity: 1; + } + } diff --git a/src/components/ui/Panel.svelte b/src/components/ui/Panel.svelte index 0f1aba3..cd1a32d 100644 --- a/src/components/ui/Panel.svelte +++ b/src/components/ui/Panel.svelte @@ -5,8 +5,8 @@ interface Props { title: string children: Snippet - /** Optional strip under the body, like the action row on the in-game business cards. */ - footer?: Snippet + /** Small controls at the right end of the title bar, e.g. the settings gear. */ + actions?: Snippet /** * Makes the title bar a handle to move the panel: these handlers go on it, it takes focus, * and the parent does the moving (`CardGrid`, decision 026). @@ -18,32 +18,34 @@ focus?: string } - let { title, children, footer, move, moving = false, focus }: Props = $props() + let { title, children, actions, move, moving = false, focus }: Props = $props() const id = $props.id()
    -

    - {#if move} -
    - {@render heading()} -
    - - {:else} -
    {@render heading()}
    +
    +

    + {#if move} +
    + {@render heading()} +
    + + {:else} +
    {@render heading()}
    + {/if} +

    + {#if actions} +
    {@render actions()}
    {/if} -

    +
    {@render children()}
    - {#if footer} - - {/if} {#snippet heading()} @@ -60,13 +62,33 @@ min-width: 0; } - h3 { + .head { background: var(--panel-raised); border-bottom: 1px solid var(--line); + position: relative; + } + + h3 { font-size: var(--text-md); margin: 0; } + /* Room for the actions on both sides, so a long title neither runs under them nor + leaves the centre. */ + .head:has(.actions) .title { + padding-inline: 2.75rem; + } + + /* Over the right end of the title bar, so the title stays centred on the card. */ + .actions { + display: flex; + gap: var(--space-1); + position: absolute; + right: var(--space-2); + top: 50%; + transform: translateY(-50%); + } + .title { align-items: center; display: flex; @@ -112,12 +134,4 @@ gap: var(--space-3); padding: var(--space-3) var(--space-4); } - - .footer { - border-top: 1px solid var(--line); - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - padding: var(--space-2) var(--space-4); - } diff --git a/src/components/ui/SettingsButton.svelte b/src/components/ui/SettingsButton.svelte index 068d8c8..eeb7398 100644 --- a/src/components/ui/SettingsButton.svelte +++ b/src/components/ui/SettingsButton.svelte @@ -1,6 +1,7 @@ - - - + diff --git a/src/components/ui/UndoStatus.svelte b/src/components/ui/UndoStatus.svelte new file mode 100644 index 0000000..fbc75e3 --- /dev/null +++ b/src/components/ui/UndoStatus.svelte @@ -0,0 +1,35 @@ + + +{#if offer} + + {offer.text}. + + +{/if} + + diff --git a/src/data/business-events.ts b/src/data/business-events.ts index 36114ce..dc4a23e 100644 --- a/src/data/business-events.ts +++ b/src/data/business-events.ts @@ -12,7 +12,7 @@ export interface BusinessEvent { checked: string } -export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { +export const BUSINESS_EVENTS: Record<'sold', BusinessEvent> = { sold: { label: 'Sold', empties: ['stock'], @@ -20,15 +20,6 @@ export const BUSINESS_EVENTS: Record<'sold' | 'raided', BusinessEvent> = { source: 'Maintainer, from play (issue #4): selling empties the stock, supplies stay.', checked: '2026-10-14', }, - raided: { - label: 'Raided', - empties: ['stock', 'supplies'], - cancelsDelivery: true, - source: - 'Maintainer, from play (issue #4): a successful police raid zeroes stock and supplies ' + - 'and cancels supplies still on their way.', - checked: '2026-10-14', - }, } /** Buying supplies: they arrive after a delay and fill the supplies bar. */ diff --git a/src/data/production.ts b/src/data/production.ts index 640d5bc..4cb4c3b 100644 --- a/src/data/production.ts +++ b/src/data/production.ts @@ -48,8 +48,6 @@ export interface Production { valueBonus?: { upgrade: string; bonus: number } /** Owning another property raises the sale value, e.g. a Money Front (decision 019). */ ownedBonus?: OwnedBonus - /** False if the business cannot be raided. */ - raids?: false /** A production boost the player can start in the business. */ boost?: ProductionBoost /** A choice of what the staff work on, e.g. manufacturing or research. */ @@ -264,7 +262,6 @@ export const PRODUCTION: readonly Production[] = [ unitName: 'units', maxUnits: 160, valueBonus: { upgrade: 'named', bonus: 0.05 }, - raids: false, boost: { units: 80, maxRealMs: 24 * 60 * 60_000, diff --git a/src/lib/cards.svelte.ts b/src/lib/cards.svelte.ts index 2dcc639..0316b5f 100644 --- a/src/lib/cards.svelte.ts +++ b/src/lib/cards.svelte.ts @@ -1,51 +1,28 @@ -// Which card on a tab has its edit controls or its settings open (issue #60): one card and one -// mode at a time, so Edit and the gear never stack on the same card. -// -// Trial (issue #60): two ways to edit a card are on offer until one is picked. `exclusive` keeps -// the Edit button; `always` drops it and shows the edit controls on every card. Remove the -// switch and the losing style once picked. +// An Undo offered for a while right after a quick press, where the press was (issue #60): a +// boost started, a spin marked, an Overview action. A UI timer, not game time (decision 003 +// covers tracker logic only). -export type EditStyle = 'exclusive' | 'always' +export const UNDO_MS = 15_000 -const STYLE_KEY = 'lsos-trial-edit-style' +export class TimedUndo { + /** The press that can be taken back now: where it was (`key`), what it did, how to undo. */ + current = $state<{ key: string; text: string; run: () => void } | null>(null) + #timer: ReturnType | undefined -function savedStyle(): EditStyle { - try { - return localStorage.getItem(STYLE_KEY) === 'always' ? 'always' : 'exclusive' - } catch { - return 'exclusive' - } -} - -export const editTrial = $state({ style: savedStyle() }) - -export function setEditStyle(style: EditStyle) { - editTrial.style = style - try { - localStorage.setItem(STYLE_KEY, style) - } catch { - // Not saved; the choice lasts until reload. - } -} - -export type CardMode = 'edit' | 'settings' - -export class CardModes { - #open = $state<{ id: string; mode: CardMode } | null>(null) - - /** Whether the card shows this mode. */ - is(id: string, mode: CardMode): boolean { - return this.#open?.id === id && this.#open.mode === mode + offer(key: string, text: string, run: () => void) { + this.current = { key, text, run } + clearTimeout(this.#timer) + this.#timer = setTimeout(() => (this.current = null), UNDO_MS) } - /** Whether the card shows its edit controls: always on in the `always` style, except while - * its settings are open. */ - editing(id: string): boolean { - return editTrial.style === 'always' ? !this.is(id, 'settings') : this.is(id, 'edit') + /** The offer at this place, if any. */ + at(key: string) { + return this.current?.key === key ? this.current : null } - /** Opens the mode on the card, closing any other; on again closes it. */ - toggle(id: string, mode: CardMode) { - this.#open = this.is(id, mode) ? null : { id, mode } + take() { + this.current?.run() + this.current = null + clearTimeout(this.#timer) } } From f59e4ef85a9e6a1fe378fe1bec10df56671ed9e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:41:24 +0000 Subject: [PATCH 5/6] Order properties by use; drag handles grab at the bar ends; shared checkbox style USE_ORDER sorts Settings and the default card order: Acid Lab, Bunker, Nightclub, MC businesses by money per hour, fronts and safes, then the rest with the Mansion and Bail Office last. The slider's hit area reaches half a handle past each end. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/029-shared-ui.md | 10 +++++++ docs/decisions/README.md | 2 +- src/app.css | 8 +++++ src/components/PropertyUpgrades.svelte | 7 ----- src/components/Safes.svelte | 13 ++++---- src/components/Settings.svelte | 6 ---- src/components/ui/Meter.svelte | 11 +++++-- src/data/properties.ts | 41 +++++++++++++++++++++++++- src/lib/cards.svelte.ts | 2 +- src/lib/properties.test.ts | 8 ++++- src/lib/timers.test.ts | 2 +- 11 files changed, 85 insertions(+), 25 deletions(-) diff --git a/docs/decisions/029-shared-ui.md b/docs/decisions/029-shared-ui.md index 427ed43..835a836 100644 --- a/docs/decisions/029-shared-ui.md +++ b/docs/decisions/029-shared-ui.md @@ -31,6 +31,16 @@ - **Money Fronts heat:** each front's bar has its three actions right under it, as small buttons in one row: +10% Laundering, +10% Faber, Legal job. - **One card width:** `--card-width` (20rem) for every card grid. +- **Order by use:** `USE_ORDER` in `src/data/properties.ts` sorts Settings and gives each tab + its card order until the user moves the cards: + - Acid Lab, Bunker and Nightclub first. + - Then the MC businesses by money per hour at full upgrades, from `production.ts`: Cocaine, + Meth, Cash, Weed, Documents. + - Then the Money Fronts and the safes. The properties that only unlock something or add to a + safe come last, with the Mansion and the Bail Enforcement Office at the bottom. + - A test checks that the list names every property. +- **Bars grab at their ends:** the draggable area reaches half a handle past each end of the + bar, so a handle at 0% or 100% can be grabbed anywhere on it. - **Settings is a list with a detail panel:** - Every property is a row that keeps its height, with a gear in front of its name, a one-line upgrade summary, and Owned (and Running) buttons in fixed-width slots. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 937ab04..f81ccfd 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -39,4 +39,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab - [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC - [028](028-hide-from-overview.md) Hide from Overview: per card (event `focus`, timers too), Hide on each item with undo, Settings lists them with Show; still tracked and counted in the totals; showing again brings no alert -- [029](029-shared-ui.md) Shared card parts (`Hint`, `Note`, `MarkOwned`, timed `UndoStatus`, small buttons); cards always editable, drag handles on hover, no Full/Raided; gear in the title bar, no footer; `--card-width`; Settings as a fixed-row property list with the picked property's upgrades beside it +- [029](029-shared-ui.md) Shared card parts (`Hint`, `Note`, `MarkOwned`, timed `UndoStatus`, small buttons); cards always editable, drag handles on hover, no Full/Raided; gear in the title bar, no footer; `--card-width`; default order by use (`USE_ORDER`); Settings as a fixed-row property list with the picked property's upgrades beside it diff --git a/src/app.css b/src/app.css index 9f82bdf..0d37224 100644 --- a/src/app.css +++ b/src/app.css @@ -106,6 +106,14 @@ a { color: var(--accent-text); } +/* Every checkbox in the theme's green, a little larger than the browser's. */ +input[type='checkbox'] { + accent-color: var(--accent); + flex: none; + height: 1.125rem; + width: 1.125rem; +} + :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; diff --git a/src/components/PropertyUpgrades.svelte b/src/components/PropertyUpgrades.svelte index da220bc..1aab7b8 100644 --- a/src/components/PropertyUpgrades.svelte +++ b/src/components/PropertyUpgrades.svelte @@ -91,13 +91,6 @@ display: contents; } - input[type='checkbox'] { - accent-color: var(--accent); - flex: none; - height: 1.125rem; - width: 1.125rem; - } - .visually-hidden { clip-path: inset(50%); height: 1px; diff --git a/src/components/Safes.svelte b/src/components/Safes.svelte index f4930ac..1908f7a 100644 --- a/src/components/Safes.svelte +++ b/src/components/Safes.svelte @@ -50,11 +50,14 @@ const frontProperties = $derived( FRONTS.flatMap((front) => catalog.filter((p) => p.id === front.propertyId)), ) - // Money Fronts first until moved, then the property safes. - const cards = $derived([ - ...(fronts ? [{ id: CAR_WASH, label: 'Money Fronts' }] : []), - ...shown.map(({ property }) => ({ id: property.id, label: property.label })), - ]) + // In the catalog's order until moved (issue #60); Money Fronts goes where the Car Wash is. + const place = (id: string) => catalog.findIndex((p) => p.id === id) + const cards = $derived( + [ + ...(fronts ? [{ id: CAR_WASH, label: 'Money Fronts' }] : []), + ...shown.map(({ property }) => ({ id: property.id, label: property.label })), + ].sort((a, b) => place(a.id) - place(b.id)), + ) // The card whose settings are open, one at a time. let open = $state(null) diff --git a/src/components/Settings.svelte b/src/components/Settings.svelte index a7a86ed..06ca59a 100644 --- a/src/components/Settings.svelte +++ b/src/components/Settings.svelte @@ -232,12 +232,6 @@ gap: var(--space-2); } - input[type='checkbox'] { - accent-color: var(--accent); - height: 1.125rem; - width: 1.125rem; - } - .row { align-items: center; display: grid; diff --git a/src/components/ui/Meter.svelte b/src/components/ui/Meter.svelte index 737cbaa..cbff4d6 100644 --- a/src/components/ui/Meter.svelte +++ b/src/components/ui/Meter.svelte @@ -111,7 +111,10 @@ onkeydown={key} > {@render bar()} -
    +
    {:else}
    { + const index = (USE_ORDER as readonly string[]).indexOf(property.id) + return index === -1 ? USE_ORDER.length : index +} + +/** Every property, in `USE_ORDER`. */ +export const PROPERTIES: readonly Property[] = [...CATALOG].sort((a, b) => rank(a) - rank(b)) + function mc(id: string, label: string): Property { return { id, diff --git a/src/lib/cards.svelte.ts b/src/lib/cards.svelte.ts index 0316b5f..1f5e1b0 100644 --- a/src/lib/cards.svelte.ts +++ b/src/lib/cards.svelte.ts @@ -2,7 +2,7 @@ // boost started, a spin marked, an Overview action. A UI timer, not game time (decision 003 // covers tracker logic only). -export const UNDO_MS = 15_000 +const UNDO_MS = 15_000 export class TimedUndo { /** The press that can be taken back now: where it was (`key`), what it did, how to undo. */ diff --git a/src/lib/properties.test.ts b/src/lib/properties.test.ts index 087d2a4..1671ecb 100644 --- a/src/lib/properties.test.ts +++ b/src/lib/properties.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Property, Upgrade } from '../data/properties' +import { PROPERTIES, USE_ORDER, type Property, type Upgrade } from '../data/properties' import { isOwned, isRunning, @@ -92,6 +92,12 @@ describe('upgradeValue', () => { }) }) +describe('USE_ORDER', () => { + it('lists every property once, so each has its place', () => { + expect(PROPERTIES.map((p) => p.id)).toEqual([...USE_ORDER]) + }) +}) + describe('upgradeSummary', () => { it('names the bought upgrades and leaves out the first choice', () => { const property = sample('lab') diff --git a/src/lib/timers.test.ts b/src/lib/timers.test.ts index f08c794..ecd1b57 100644 --- a/src/lib/timers.test.ts +++ b/src/lib/timers.test.ts @@ -59,7 +59,7 @@ describe('boostableBusinesses', () => { ).map((p) => p.id) it('lists owned, running businesses the boost covers', () => { - expect(ids(owned)).toEqual(['mc-meth', 'mc-weed', 'bunker']) + expect(ids(owned)).toEqual(['bunker', 'mc-meth', 'mc-weed']) expect(ids(setShutDown(owned, 'bunker', true))).toEqual(['mc-meth', 'mc-weed']) }) From b4e54f9ffd6c2b38cbbc0df38eac3653fd10bfcd Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 20:46:28 +0000 Subject: [PATCH 6/6] Drop the popularity Maxed preset Same idea as the removed Full buttons: drag the bar or type the value. Co-Authored-By: Claude Opus 5.5 --- docs/decisions/029-shared-ui.md | 4 ++-- docs/decisions/README.md | 2 +- src/components/SafeCard.svelte | 13 +++++-------- src/data/safes.ts | 2 +- 4 files changed, 9 insertions(+), 12 deletions(-) diff --git a/docs/decisions/029-shared-ui.md b/docs/decisions/029-shared-ui.md index 835a836..bd82d7c 100644 --- a/docs/decisions/029-shared-ui.md +++ b/docs/decisions/029-shared-ui.md @@ -24,8 +24,8 @@ - The read-only extras stay, next to the editable value: units, "of $cap", value per goods. - Decided after trying both styles side by side. - **Fewer one-click presets** (amends 010): no Full on stock, safes or goods, and no Raided. - Sold, Supplies bought, Collected and popularity Maxed stay. Rarely used, and every value can - be typed or dragged instead. + No Maxed on popularity either. Sold, Supplies bought, Collected and the popularity boosts stay. + Rarely used, and every value can be typed or dragged instead. - **No card footer.** The settings gear is a small button at the right end of the title bar. One card per tab has its settings open at a time. - **Money Fronts heat:** each front's bar has its three actions right under it, as small diff --git a/docs/decisions/README.md b/docs/decisions/README.md index f81ccfd..dd28190 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -39,4 +39,4 @@ free number, add one line below. **Replacing one:** write a new decision, then m - [026](026-movable-cards-everywhere.md) Movable cards on Businesses, Safes and Timers (`ui/CardGrid`): picked up by the title bar; swap by the dragged card's middle, 10% in; orders saved per tab - [027](027-weekly-contents.md) Weekly events: typed `src/data/weekly.ts` (no JSON, no fetch), checked by a test; Weekly tab only (no news bar); out of date: warning over the dimmed old week; contents: multipliers, Weekly Challenge, rotations, discounts, gifts, a less prominent "Also running" list; no GTA+, no daily map-bound items; explicit UTC start/end (Thursday 2:00 AM US Pacific); source gta.wiki weekly page; routine Thursday ~16:00 UTC - [028](028-hide-from-overview.md) Hide from Overview: per card (event `focus`, timers too), Hide on each item with undo, Settings lists them with Show; still tracked and counted in the totals; showing again brings no alert -- [029](029-shared-ui.md) Shared card parts (`Hint`, `Note`, `MarkOwned`, timed `UndoStatus`, small buttons); cards always editable, drag handles on hover, no Full/Raided; gear in the title bar, no footer; `--card-width`; default order by use (`USE_ORDER`); Settings as a fixed-row property list with the picked property's upgrades beside it +- [029](029-shared-ui.md) Shared card parts (`Hint`, `Note`, `MarkOwned`, timed `UndoStatus`, small buttons); cards always editable, drag handles on hover, no Full/Raided/Maxed; gear in the title bar, no footer; `--card-width`; default order by use (`USE_ORDER`); Settings as a fixed-row property list with the picked property's upgrades beside it diff --git a/src/components/SafeCard.svelte b/src/components/SafeCard.svelte index 8ac43de..98f52e0 100644 --- a/src/components/SafeCard.svelte +++ b/src/components/SafeCard.svelte @@ -60,14 +60,11 @@ label={est('Popularity', estimated.popularity)} value={popularity} segments={BAR_SEGMENTS.count} - presets={[ - { label: 'Maxed', value: 100, title: 'Full bar, e.g. after switching to a new DJ' }, - ...(safe.popularity?.boosts ?? []).map((boost) => ({ - label: boost.label, - value: Math.min(100, popularity + boost.adds), - title: boost.title, - })), - ]} + presets={(safe.popularity?.boosts ?? []).map((boost) => ({ + label: boost.label, + value: Math.min(100, popularity + boost.adds), + title: boost.title, + }))} onresync={(value) => onresync({ ...current, popularity: value })} /> {#if payout < best} diff --git a/src/data/safes.ts b/src/data/safes.ts index e07b766..8f23675 100644 --- a/src/data/safes.ts +++ b/src/data/safes.ts @@ -111,7 +111,7 @@ export const SAFES: readonly Safe[] = [ { label: 'DJ +10%', adds: 10, - title: 'Switching back to a DJ you had before (a new DJ fills the bar: use Maxed)', + title: 'Switching back to a DJ you had before (a new DJ fills the bar: drag it to full)', }, { label: 'Event +10% (est.)',