diff --git a/assets/styles.css b/assets/styles.css index e111f3f..6288f7d 100644 --- a/assets/styles.css +++ b/assets/styles.css @@ -277,6 +277,56 @@ button.data-status:hover { background: var(--surface-soft); } .local-book-option strong, .local-book-option small { display: block; } .local-book-option strong { min-height: 2.4em; overflow-wrap: anywhere; font-size: 10px; line-height: 1.2; } .local-book-option small { margin-top: 3px; color: var(--faint); font: 8px var(--font-mono); } +.rules-data-intro-popover { + position: fixed; + right: 24px; + bottom: 88px; + z-index: 90; + width: min(390px, calc(100vw - 32px)); + padding: 24px; + overflow: hidden; + border: 1px solid var(--line-strong); + border-radius: 12px; + background: + radial-gradient(circle at 100% 0%, rgba(201, 255, 82, .13), transparent 38%), + var(--surface-raised); + box-shadow: 0 28px 90px rgba(0, 0, 0, .62); +} +.rules-data-intro-popover::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 3px; + background: var(--accent); +} +.rules-data-intro-popover h2 { + margin: 10px 0 8px; + font: 800 23px/1.08 var(--font-display); + letter-spacing: -.025em; +} +.rules-data-intro-popover > p { + margin: 0; + color: var(--muted); + font-size: 11px; + line-height: 1.55; +} +.rules-data-intro-popover .rules-data-intro-warning { + margin-top: 14px; + padding: 10px 12px; + border-left: 2px solid var(--warning); + color: var(--ink); + background: rgba(255, 206, 103, .06); + font-size: 10px; +} +.rules-data-intro-actions { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + margin-top: 18px; +} +.rules-data-intro-actions button { + min-height: 42px; +} @media (max-width: 1100px) { .rules-data-books-panel .local-book-list { grid-template-columns: 1fr; } } @@ -287,6 +337,20 @@ button.data-status:hover { background: var(--surface-soft); } .rules-data-actions { grid-template-columns: 1fr; } .rules-data-books-panel .local-book-list { grid-template-columns: 1fr; } } +@media (max-width: 800px) { + .rules-data-intro-popover { + right: 16px; + bottom: 86px; + } +} +@media (max-width: 440px) { + .rules-data-intro-popover { + padding: 20px; + } + .rules-data-intro-actions { + grid-template-columns: 1fr; + } +} .table-invite-link { margin-top: 10px; padding: 10px; border: 1px solid var(--line); background: rgba(0, 0, 0, .12); } .table-invite-link > span { display: block; margin-bottom: 5px; color: var(--muted); font: 8px var(--font-mono); text-transform: uppercase; } .table-invite-link a { display: block; overflow: hidden; color: var(--accent); font: 9px var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } @@ -1588,6 +1652,21 @@ button.data-status:hover { background: var(--surface-soft); } .score-control button:hover:not(:disabled) { color: #111; border-color: var(--accent); background: var(--accent); } .score-control button:disabled { opacity: .3; cursor: not-allowed; } .score-control strong { text-align: center; font: 700 27px var(--font-display); } +.ability-score-total { + min-width: 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; +} +.ability-score-total > span { + color: var(--faint); + font: 600 7px/1.2 var(--font-mono); + letter-spacing: .02em; + text-align: center; + text-transform: uppercase; + white-space: nowrap; +} .modifier { margin-top: 8px; min-width: 38px; padding: 4px 7px; text-align: center; color: #dce0dc; background: #111411; font: 500 11px var(--font-mono); } .roll-modifier { padding: 0; @@ -3988,7 +4067,7 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display padding: 0 14px; } .sheet-builder-layout { - grid-template-columns: minmax(0, 1fr) 270px; + grid-template-columns: minmax(0, 1fr); gap: 14px; } .sheet-builder-decision, @@ -4067,17 +4146,45 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display font: 700 11px var(--font-mono); } .sheet-builder-summary { + position: static; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(170px, .35fr) minmax(170px, .35fr); + grid-template-rows: auto auto auto auto; + align-items: center; + column-gap: 24px; padding: 22px; background: linear-gradient(160deg, #171b18, #111310); } .sheet-builder-summary::before { content: ""; + grid-column: 1 / -1; height: 3px; display: block; margin: -22px -22px 20px; border-radius: 8px 8px 0 0; background: var(--accent); } +.sheet-builder-summary > .sheet-overline { + grid-column: 1; + grid-row: 2; +} +.sheet-builder-summary > h3 { + grid-column: 1; + grid-row: 3; +} +.sheet-builder-summary > p { + grid-column: 1; + grid-row: 4; +} +.sheet-builder-summary > .sheet-builder-summary-score { + grid-column: 2; + grid-row: 2 / 5; + margin: 0; +} +.sheet-builder-summary > .sheet-builder-summary-list { + grid-column: 3; + grid-row: 2 / 5; +} .sheet-builder-summary-score { padding: 14px; border: 1px solid rgba(201,255,82,.2); @@ -4935,6 +5042,10 @@ footer { width: min(1480px, 94vw); margin: 0 auto; padding: 20px 0 34px; display } .sheet-builder-summary { position: static; + display: block; + } + .sheet-builder-summary > .sheet-builder-summary-score { + margin: 23px 0; } .inventory-inspector, .companion-inspector { diff --git a/src/sheets.gleam b/src/sheets.gleam index 68a11c0..0efcd18 100644 --- a/src/sheets.gleam +++ b/src/sheets.gleam @@ -39,7 +39,7 @@ import sheets/peer.{ import sheets/ui import varasto -const abilities_note = "Use the 2024 standard array: 15, 14, 13, 12, 10, 8" +const abilities_note = "Set base scores using the 2024 standard array: 15, 14, 13, 12, 10, 8. Background bonuses from Foundation are applied automatically." pub type Ability { Strength @@ -368,6 +368,15 @@ pub type InviteCopyStatus { InviteCopyFailed } +pub type RulesDataIntro { + RulesDataIntroLoading( + rules_data_url: Result(String, Nil), + seen: Result(Bool, Nil), + ) + RulesDataIntroOpen + RulesDataIntroClosed +} + pub type Model { Model( character_id: String, @@ -470,6 +479,7 @@ pub type Model { rules_data_url: String, rules_data_url_input: String, rules_data_url_error: String, + rules_data_intro: RulesDataIntro, peer_name: String, join_code: String, connection: Connection, @@ -518,6 +528,8 @@ const library_storage_key = "sheets.character-library" const rules_data_url_storage_key = "sheets.rules-data-url" +const rules_data_intro_seen_storage_key = "sheets.rules-data-intro-seen" + const blocked_sources_storage_key = "sheets.master-blocked-sources" const local_blocked_sources_storage_key = "sheets.character-blocked-sources" @@ -529,6 +541,8 @@ pub type Message { UserChangedRulesDataUrl(String) UserSavedRulesDataUrl RulesDataUrlLoaded(String) + RulesDataIntroSeenLoaded(Bool) + UserDismissedRulesDataIntro MasterBlockedSourcesLoaded(List(String)) LocalBlockedSourcesLoaded(List(String)) UserToggledLocalBlockedSource(String) @@ -906,6 +920,10 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { rules_data_url: "", rules_data_url_input: "", rules_data_url_error: "", + rules_data_intro: RulesDataIntroLoading( + rules_data_url: Error(Nil), + seen: Error(Nil), + ), peer_name: "", join_code: invited_table, connection: Disconnected, @@ -941,6 +959,7 @@ fn init(_flags: Nil) -> #(Model, Effect(Message)) { load_player_character(), load_saved_player_characters(), load_rules_data_url(), + load_rules_data_intro_seen(), load_master_blocked_sources(), load_local_blocked_sources(), modem.init(UrlChanged), @@ -1104,6 +1123,57 @@ fn load_origins_for_character( ) } +fn resolve_rules_data_intro( + rules_data_url: Result(String, Nil), + seen: Result(Bool, Nil), + active_page: PageTab, +) -> RulesDataIntro { + case rules_data_url, seen { + Ok(""), Ok(False) -> + case active_page == RulesDataPage { + True -> RulesDataIntroClosed + False -> RulesDataIntroOpen + } + Ok(""), Ok(True) -> RulesDataIntroClosed + Ok(_), Ok(False) | Ok(_), Ok(True) -> RulesDataIntroClosed + Ok(rules_data_url), Error(Nil) -> + RulesDataIntroLoading( + rules_data_url: Ok(rules_data_url), + seen: Error(Nil), + ) + Error(Nil), Ok(seen) -> + RulesDataIntroLoading(rules_data_url: Error(Nil), seen: Ok(seen)) + Error(Nil), Error(Nil) -> + RulesDataIntroLoading(rules_data_url: Error(Nil), seen: Error(Nil)) + } +} + +fn rules_data_intro_with_url( + intro: RulesDataIntro, + rules_data_url: String, + active_page: PageTab, +) -> RulesDataIntro { + case intro { + RulesDataIntroLoading(rules_data_url: _, seen:) -> + resolve_rules_data_intro(Ok(rules_data_url), seen, active_page) + RulesDataIntroOpen -> RulesDataIntroOpen + RulesDataIntroClosed -> RulesDataIntroClosed + } +} + +fn rules_data_intro_with_seen( + intro: RulesDataIntro, + seen: Bool, + active_page: PageTab, +) -> RulesDataIntro { + case intro { + RulesDataIntroLoading(rules_data_url:, seen: _) -> + resolve_rules_data_intro(rules_data_url, Ok(seen), active_page) + RulesDataIntroOpen -> RulesDataIntroOpen + RulesDataIntroClosed -> RulesDataIntroClosed + } +} + fn normalize_rules_data_url(input: String) -> Result(String, String) { let trimmed = string.trim(input) case trimmed { @@ -1417,9 +1487,10 @@ fn import_character_library( fn remove_origin_selections( selections: List(FeatureSelection), + prefix: String, ) -> List(FeatureSelection) { list.filter(selections, fn(selection) { - !string.starts_with(selection.id, "origin:") + !string.starts_with(selection.id, prefix) }) } @@ -1636,8 +1707,38 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { effect.none(), ) } - RulesDataUrlLoaded(rules_data_url) -> - apply_rules_data_url(model, rules_data_url) + RulesDataUrlLoaded(rules_data_url) -> { + let #(updated, loading) = apply_rules_data_url(model, rules_data_url) + #( + Model( + ..updated, + rules_data_intro: rules_data_intro_with_url( + model.rules_data_intro, + rules_data_url, + model.active_page, + ), + ), + loading, + ) + } + RulesDataIntroSeenLoaded(seen) -> #( + Model( + ..model, + rules_data_intro: rules_data_intro_with_seen( + model.rules_data_intro, + seen, + model.active_page, + ), + ), + case seen, model.active_page { + False, RulesDataPage -> persist_rules_data_intro_seen() + _, _ -> effect.none() + }, + ) + UserDismissedRulesDataIntro -> #( + Model(..model, rules_data_intro: RulesDataIntroClosed), + persist_rules_data_intro_seen(), + ) UserChangedRulesDataUrl(value) -> #( Model(..model, rules_data_url_input: value, rules_data_url_error: ""), effect.none(), @@ -1649,10 +1750,15 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { effect.none(), ) Ok(rules_data_url) -> { - let #(updated, loading) = apply_rules_data_url(model, rules_data_url) + let #(loaded, loading) = apply_rules_data_url(model, rules_data_url) + let updated = Model(..loaded, rules_data_intro: RulesDataIntroClosed) #( updated, - effect.batch([persist_rules_data_url(rules_data_url), loading]), + effect.batch([ + persist_rules_data_url(rules_data_url), + persist_rules_data_intro_seen(), + loading, + ]), ) } } @@ -1979,6 +2085,20 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { ), push_navigation(CharacterBuilderPage, OriginsTab), ) + UserSelectedPage(RulesDataPage) -> #( + Model( + ..model, + active_page: RulesDataPage, + rules_data_intro: RulesDataIntroClosed, + ), + effect.batch([ + push_navigation( + RulesDataPage, + core_tab_to_character_tab(model.active_core_tab), + ), + persist_rules_data_intro_seen(), + ]), + ) UserSelectedPage(page) -> case page_navigation_enabled(model, page) { True -> #( @@ -2189,14 +2309,20 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model( ..model, species_name: name, - origin_details: Error("Loading origin features"), - skills: model.skills - |> remove_selected_origin_skills(model.feature_selections) - |> remove_granted_origin_skills(model.origin_details), - other_proficiencies: model.other_proficiencies - |> remove_selected_origin_tools(model.feature_selections) - |> remove_granted_origin_tools(model.origin_details), - feature_selections: remove_origin_selections(model.feature_selections), + skills: remove_selected_origin_skills( + model.skills, + model.feature_selections, + "origin:species:", + ), + other_proficiencies: remove_selected_origin_tools( + model.other_proficiencies, + model.feature_selections, + "origin:species:", + ), + feature_selections: remove_origin_selections( + model.feature_selections, + "origin:species:", + ), ) let #(updated, persistence) = update_and_sync(model, updated) #( @@ -2216,14 +2342,22 @@ fn update(model: Model, message: Message) -> #(Model, Effect(Message)) { Model( ..model, background_name: name, - origin_details: Error("Loading origin features"), skills: model.skills - |> remove_selected_origin_skills(model.feature_selections) + |> remove_selected_origin_skills( + model.feature_selections, + "origin:background:", + ) |> remove_granted_origin_skills(model.origin_details), other_proficiencies: model.other_proficiencies - |> remove_selected_origin_tools(model.feature_selections) + |> remove_selected_origin_tools( + model.feature_selections, + "origin:background:", + ) |> remove_granted_origin_tools(model.origin_details), - feature_selections: remove_origin_selections(model.feature_selections), + feature_selections: remove_origin_selections( + model.feature_selections, + "origin:background:", + ), ) let #(updated, persistence) = update_and_sync(model, updated) #( @@ -4377,6 +4511,13 @@ fn rules_data_url_storage() -> Result(varasto.TypedStorage(String), Nil) { } } +fn rules_data_intro_storage() -> Result(varasto.TypedStorage(Bool), Nil) { + case varasto.local() { + Ok(storage) -> Ok(varasto.new(storage, decode.bool, json.bool)) + Error(Nil) -> Error(Nil) + } +} + fn blocked_sources_storage() -> Result(varasto.TypedStorage(List(String)), Nil) { case varasto.local() { Ok(storage) -> @@ -4548,6 +4689,33 @@ fn persist_rules_data_url(rules_data_url: String) -> Effect(Message) { }) } +fn load_rules_data_intro_seen() -> Effect(Message) { + effect.from(fn(dispatch) { + case rules_data_intro_storage() { + Error(Nil) -> dispatch(RulesDataIntroSeenLoaded(False)) + Ok(storage) -> + case varasto.get(storage, rules_data_intro_seen_storage_key) { + Ok(seen) -> dispatch(RulesDataIntroSeenLoaded(seen)) + Error(varasto.NotFound) -> dispatch(RulesDataIntroSeenLoaded(False)) + Error(varasto.DecodeError(err: _)) -> + dispatch(RulesDataIntroSeenLoaded(False)) + } + } + }) +} + +fn persist_rules_data_intro_seen() -> Effect(Message) { + effect.from(fn(_dispatch) { + case rules_data_intro_storage() { + Error(Nil) -> Nil + Ok(storage) -> { + let _ = varasto.set(storage, rules_data_intro_seen_storage_key, True) + Nil + } + } + }) +} + fn load_player_character() -> Effect(Message) { effect.from(fn(dispatch) { case library_storage() { @@ -6436,6 +6604,11 @@ fn view(model: Model) -> Element(Message) { html.div([attribute.class("sheet-root")], [ view_application_shell(model, workspace), + case model.rules_data_intro { + RulesDataIntroOpen -> view_rules_data_intro() + RulesDataIntroLoading(rules_data_url: _, seen: _) + | RulesDataIntroClosed -> html.text("") + }, case character_ready, model.dice_open { True, False -> view_global_dice_button() _, _ -> html.text("") @@ -6477,6 +6650,37 @@ fn view(model: Model) -> Element(Message) { ]) } +fn view_rules_data_intro() -> Element(Message) { + html.aside( + [ + attribute.class("rules-data-intro-popover"), + attribute.role("dialog"), + attribute.aria_label("Connect rules data"), + attribute.aria_live("polite"), + ], + [ + html.span([attribute.class("sheet-overline")], [ + html.text("FIRST-TIME SETUP"), + ]), + html.h2([], [html.text("Connect your rules library")]), + html.p([], [ + html.text( + "Add a compatible rules data URL to load complete classes, spells, equipment, species, and backgrounds.", + ), + ]), + html.p([attribute.class("rules-data-intro-warning")], [ + html.text( + "Without it, Sheets only has a limited offline fallback and some character information will be unavailable.", + ), + ]), + html.div([attribute.class("rules-data-intro-actions")], [ + ui.secondary_button("Not now", UserDismissedRulesDataIntro), + ui.primary_button("Open Rules Data", UserSelectedPage(RulesDataPage)), + ]), + ], + ) +} + fn view_application_shell( model: Model, workspace: Element(Message), @@ -12445,6 +12649,8 @@ fn view_ability_scores(model: Model) -> Element(Message) { html.div( [attribute.class("ability-grid")], list.map(ability_list(), fn(ability) { + let base_score = ability_score(model.abilities, ability) + let background_bonus = background_ability_bonus(model, ability) let score = effective_ability_score(model, ability) let modifier = modifier(score) html.article( @@ -12467,12 +12673,22 @@ fn view_ability_scores(model: Model) -> Element(Message) { [ attribute.type_("button"), attribute.aria_label("Decrease " <> ability_name(ability)), - attribute.disabled(score <= 3), + attribute.disabled(base_score <= 3), event.on_click(UserChangedAbility(ability, -1)), ], [html.text("−")], ), - html.strong([], [html.text(int.to_string(score))]), + html.div([attribute.class("ability-score-total")], [ + html.strong([], [html.text(int.to_string(score))]), + html.span([], [ + html.text( + int.to_string(base_score) + <> " base + " + <> int.to_string(background_bonus) + <> " background", + ), + ]), + ]), html.button( [ attribute.type_("button"), @@ -12834,8 +13050,15 @@ fn remove_selected_class_skills( fn remove_selected_origin_skills( skills: List(String), selections: List(FeatureSelection), + prefix: String, ) -> List(String) { - list.filter(skills, fn(skill) { !origin_skill_is_selected(selections, skill) }) + list.filter(skills, fn(skill) { + !list.any(selections, fn(selection) { + string.starts_with(selection.id, prefix) + && is_skill_name(selection.value) + && selection.value == skill + }) + }) } fn is_skill_name(value: String) -> Bool { @@ -12889,10 +13112,11 @@ fn add_non_skill_proficiency( fn remove_selected_origin_tools( proficiencies: List(String), selections: List(FeatureSelection), + prefix: String, ) -> List(String) { list.filter(proficiencies, fn(proficiency) { !list.any(selections, fn(selection) { - string.starts_with(selection.id, "origin:") + string.starts_with(selection.id, prefix) && !is_skill_name(selection.value) && selection.value == proficiency && { @@ -15686,14 +15910,17 @@ fn feat_levels(class_name: String, level: Int) -> List(Int) { list.filter(class_levels, fn(feat_level) { feat_level <= level }) } +fn background_ability_bonus(model: Model, ability: Ability) -> Int { + feature_selection_value( + model.feature_selections, + "origin:background:ability-scores", + ) + |> data.ability_allocation_bonus(ability_name(ability)) +} + fn effective_ability_score(model: Model, ability: Ability) -> Int { let ability_name = ability_name(ability) - let background_bonus = - feature_selection_value( - model.feature_selections, - "origin:background:ability-scores", - ) - |> data.ability_allocation_bonus(ability_name) + let background_bonus = background_ability_bonus(model, ability) let standard_score = ability_score(model.abilities, ability) + background_bonus diff --git a/src/sheets/sound_ffi.mjs b/src/sheets/sound_ffi.mjs index 7e8a13a..52b7e0f 100644 --- a/src/sheets/sound_ffi.mjs +++ b/src/sheets/sound_ffi.mjs @@ -1,7 +1,7 @@ import { bind, play } from "cuelume"; let buttonObserver; -let sidebarHoverInstalled = false; +let immediateHoverInstalled = false; function synchronizeButtonFeedback(button) { const generatedAttributes = ( @@ -15,23 +15,19 @@ function synchronizeButtonFeedback(button) { } delete button.dataset.sheetsGeneratedSoundAttributes; - const sidebarButton = button.closest(".sheet-sidebar") !== null; + // Remove the marker used by the former sidebar-only workaround. All button + // hover attributes now use the immediate delegated handler below. button.removeAttribute("data-sheets-hover-sound"); - if (sidebarButton) button.removeAttribute("data-cuelume-hover"); if (button.disabled) return; const defaults = [ - ...(sidebarButton ? [] : [["data-cuelume-hover", "tick"]]), + ["data-cuelume-hover", "tick"], ["data-cuelume-press", "press"], ["data-cuelume-release", "release"], ]; const generated = []; - if (sidebarButton) { - button.setAttribute("data-sheets-hover-sound", "tick"); - } - for (const [attribute, sound] of defaults) { if (!button.hasAttribute(attribute)) { button.setAttribute(attribute, sound); @@ -44,13 +40,15 @@ function synchronizeButtonFeedback(button) { } } -function installSidebarHoverFeedback() { - if (sidebarHoverInstalled) return; - sidebarHoverInstalled = true; +function installImmediateHoverFeedback() { + if (immediateHoverInstalled) return; + immediateHoverInstalled = true; - // Cuelume intentionally applies one global 150 ms hover throttle. Sidebar - // items are tightly stacked, so a quick pointer sweep skips middle items. - // Play their short tick directly, once per pointer entry, without queuing. + // Cuelume intentionally applies one global 150 ms hover throttle. Dense + // lists, grids, and navigation can therefore skip controls during a quick + // pointer sweep. Play every button hover directly and hide its declarative + // attribute from Cuelume only for this event, preventing duplicate playback + // while preserving explicit sound choices in the DOM. document.addEventListener( "pointerenter", (event) => { @@ -62,18 +60,29 @@ function installSidebarHoverFeedback() { return; } - const button = event.target.closest( - "button[data-sheets-hover-sound]", - ); - if (!button || button.disabled) return; - if (event.target !== button) return; + const button = event.target.closest("button[data-cuelume-hover]"); + if (!button || event.target !== button) return; + + const configuredSound = button.getAttribute("data-cuelume-hover"); + button.removeAttribute("data-cuelume-hover"); + setTimeout(() => { + if ( + button.isConnected && + !button.disabled && + !button.hasAttribute("data-cuelume-hover") + ) { + button.setAttribute("data-cuelume-hover", configuredSound ?? ""); + } + }, 0); + + if (button.disabled) return; const relatedTarget = event.relatedTarget; if (relatedTarget instanceof Node && button.contains(relatedTarget)) { return; } - play("tick"); + play(configuredSound || "chime"); }, true, ); @@ -111,7 +120,7 @@ function installButtonFeedback() { } export function install() { - installSidebarHoverFeedback(); + installImmediateHoverFeedback(); installButtonFeedback(); bind(); }