diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a7ceae..55d618f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: run: pytest - name: Run JS plugin-API tests - run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' + run: node --test tests/js/*.test.js 'tests/plugins/*/js/*.test.js' 'plugins/*/tests/*.test.js' tailwind-fresh: # Guard that the committed static/tailwind.min.css is in sync with source. diff --git a/.gitignore b/.gitignore index 2052cf8..5aff061 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,12 @@ plugins/minigames/__pycache__/ plugins/tuner/__pycache__/ !plugins/input_setup/ !plugins/input_setup/** +!plugins/drum_highway_3d/ +!plugins/drum_highway_3d/** +plugins/drum_highway_3d/__pycache__/ +!plugins/keys_highway_3d/ +!plugins/keys_highway_3d/** +plugins/keys_highway_3d/__pycache__/ node_modules/ test-results/ playwright-report/ diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d1651..f0fc385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **The 3D Drum Highway and 3D Keys Highway are now bundled core plugins** (`plugins/drum_highway_3d/`, `plugins/keys_highway_3d/`), imported from their former standalone repos (`feedBack-plugin-drum-highway-3d`, `feedBack-plugin-keys-highway-3d`, now archived) via `git subtree` so their history is preserved. They join the other in-tree plugins-as-plugins: the loader treats them identically to user-installed ones, both are marked `"bundled": true` in their manifests, and `.gitignore` gains the matching `!plugins//` exceptions. This puts all three 3D highways (guitar, drums, keys) in one repo ahead of a visual-parity pass that ports the guitar highway's polish (bloom, sparks, themes, reactive backgrounds) to the other two — shared helper code and theme tables can now be reviewed and kept in sync in a single place. The keys plugin's existing node test suite is wired into CI (the JS test step gains a `plugins/*/tests/*.test.js` glob, +20 tests), and `static/tailwind.min.css` is regenerated since the core Tailwind build scans `plugins/**`. One deliberate behavior change ships with the bundling: the drum highway's Auto-mode predicate is **narrowed** (it used to claim any pack with `has_drum_tab` — a pack-level flag — which, now that the plugin ships to everyone and sorts before `highway_3d` in first-match-wins Auto order, would have stolen full-band packs from the guitar highway even on Lead/Bass arrangements; it now claims only drum arrangements, or packs nothing more specific can render). Picking the drum highway manually from the viz picker is unchanged. - **The tuner now tracks what tuning your instrument is *actually* in, so it prompts you to retune in BOTH directions — down to a song's tuning, and back up when the next song needs it.** The coverage check used to compare each song against your fixed instrument-profile tuning, so it only ever prompted you *away* from "home" (e.g. E → Drop C#) and stayed silent coming back (Drop C# → E), even though you'd physically retuned. It now reads the host's live **per-instrument working tuning** (`window.feedBack.workingTuning`) — what your selected instrument is currently in — so coverage is measured against your *actual* tuning and fires both ways. When you clear an auto-opened tuner, the tuner publishes that song's tuning as your instrument's live working tuning (`assumed` — an explicit "I tuned / Skip" refines it in a later PR), so the next song is judged against where you now are. **Per-instrument** — your guitar's and bass's tunings are tracked separately (keyed like the selector), so switching instruments uses the right one. Feature-detected: on a host without the working-tuning capability it falls back to the static `/api/settings` tuning (today's behavior). `plugins/tuner/screen.js` (`_playerTuning` reads `workingTuning` keyed by the selected instrument; `_publishWorkingTuning` writes on clear). Builds on the host `workingTuning` foundation (PR 1 of the series) + the instrument→chart routing (PR 2). Tests: `tests/js/tuner_auto_open.test.js` (both-directions coverage via a live Drop-D working tuning; publish-on-clear targets the right instrument slot) — 29 pass. - **`.jsonc` support for feedpak data files** (feedpak-spec §8, FEP #3 / PR #13). Hand-edited packs may now use the `.jsonc` extension (JSON with C-style `//` line and `/* */` block comments) for any data file the manifest points at — arrangements, notation sidecars, `drum_tab`, `song_timeline`, `lyrics`, and `keys`. New shared `lib/jsonc.py` provides `parse_jsonc(text)` + `load_json(path)` (auto-detects `.jsonc` by suffix, string-aware so comment-like text inside JSON string values is preserved) and is now used by every reader in `lib/sloppak.py` (six side-file sites) and `scripts/lift_keys_notation.py` (three arrangement / song_timeline read sites). The strip regex mirrors the reference validator in `feedpak-spec/tools/validate.py`. This is an additive (MINOR) change: `.jsonc` is opt-in, so any pack that keeps its data files as `.json` is unaffected and needs no regeneration. Note that a `.jsonc` file containing real comments only loads on a reader that implements §8 — a pre-this-change reader calls bare `json.loads` and fails on the comments rather than ignoring them, so don't hand out `.jsonc` packs to older hosts. Tests: `tests/test_sloppak_jsonc_load.py` (covers all six side-file types, the lift helper, and the string-boundary preservation rule end-to-end). - **The highway now loads the part that matches your selected instrument — a bass player gets the Bass arrangement, not the default Lead/guitar chart.** When you open a song without an explicit arrangement, the WebSocket handler (`server.py` `highway_ws`) reads your selected `instrument` from `config.json` (the same file it already reads for your default-arrangement preference) and routes to the matching part: **bass → the Bass arrangement**; guitar — and any unknown/future instrument (drums, keys) — falls through to the existing preference/most-notes default, which already lands on a guitar part. Previously the instrument selector only fed the tuner, so a bass player was handed a guitar chart (and a tune/coverage check then compared a 4-string bass against a 6-string part). An **explicit arrangement request always wins** (a manual arrangement switch is untouched), and a bass player's saved default-arrangement preference is still honored **within** the bass parts (so a preferred `Bass 2` / `Alt. Bass` wins over the canonical Bass), so this only changes the *default* part chosen on load. Server-only — every launch path already flows through the WS, so there's no client change. This is the instrument↔chart-routing piece the working-tuning series leans on (otherwise coverage compares across instruments). Tests: `tests/test_highway_ws_instrument_routing.py` (bass→Bass, bass-honors-pref, bass-no-bass-part→guitar, guitar→default, explicit-wins). diff --git a/plugins/drum_highway_3d/.gitignore b/plugins/drum_highway_3d/.gitignore new file mode 100644 index 0000000..46724bd --- /dev/null +++ b/plugins/drum_highway_3d/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.pyc +.DS_Store +Thumbs.db +*.swp +.vscode/ +.idea/ diff --git a/plugins/drum_highway_3d/LICENSE b/plugins/drum_highway_3d/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/plugins/drum_highway_3d/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/plugins/drum_highway_3d/README.md b/plugins/drum_highway_3d/README.md new file mode 100644 index 0000000..964f6a7 --- /dev/null +++ b/plugins/drum_highway_3d/README.md @@ -0,0 +1,69 @@ +# 3D Drum Highway — Mockup + +![3D Drum Highway mockup screenshot](screenshots/drum-highway-3d.png) + +An exploratory **pure-visual** sibling of `highway_3d`. Renders an 8-lane +drum highway (7 lanes for hand pieces + a full-width kick bar) populated +from a hardcoded demo pattern that loops indefinitely. Not yet wired to +song data, hit detection, audio, or note_detect — this is here to play +with the look-and-feel. + +To see it, load any song in the player, then pick **3D Drum Highway** +from the viz picker. The mockup animates regardless of what song is +playing. + +## Layout + +``` + [HH] [SNR] [TM1] [TM2] [FT] [CR] [RD] <- 7 lanes, left to right + | | | | | | | + v v v v v v v + ----- hit line ----------------------------- + ▓▓▓▓▓▓▓▓▓▓▓▓▓ KICK BAR ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ <- full-width kick lane +``` + +- **Drums** (snare, toms, floor tom): flat disc geometry (`CylinderGeometry`), + palette-tinted, with subtle emissive pulse on approach. Snare gets a + thin white "wires" stripe. +- **Cymbals** (hi-hat, crash, ride): faceted gem geometry (truncated + `CylinderGeometry`), metallic material, slightly translucent. +- **Kick**: full-width amber bar across the base, brighter on approach. + +## Variants visible in the demo + +| Variant | How it reads | +|---|---| +| `accent` | Bigger note + white halo ring | +| `ghost` | Hollow ring (~65% size) instead of a solid disc | +| `flam` | Main note + a small grace disc offset slightly before | +| `bell` | Ride only — adds a bright dot in the center of the gem | + +The **Fill showcase** demo pattern fires every variant at least once in +a few bars, plus a tom roll down the kit. Best read of what's possible. + +## Settings (Settings → Plugins → 3D Drum Highway) + +- **Palette** — shared 3 palettes with `highway_3d` (default / neon / pastel). +- **Demo pattern** — rock backbeat / jazz swing / fill showcase. +- **Camera angle** — 0 (down the lanes) to 1 (top-down). Default 0.35. + +All settings persist in `localStorage` under the `drum_h3d_*` prefix. + +## What this mockup is *not* doing yet + +- Reading `bundle.notes` / `bundle.chords` / `bundle.currentTime` +- Hit detection / scoring / note_detect integration +- Real drum chart parsing (the chart doesn't ship drum charts; this + would need a new arrangement format or a Guitar Pro/MIDI source) +- Sustain trails (drums don't sustain meaningfully) +- Sticking labels, double kick, foot hi-hat, cross-stick, rolls — see + the TODOs in `screen.js` for the variant backlog + +## Why a separate plugin (vs. drum mode inside highway_3d)? + +Cleaner iteration. The drum highway is its own geometry, its own +gameplay assumptions (lanes ≠ strings, no frets, no chord shapes, +no sustains), and likely its own chart format. Forking the visuals +in a sibling plugin lets the mockup move fast without risking +regressions in the guitar viz that ships today. If the drum highway +eventually matures, we can decide whether to merge or keep separate. diff --git a/plugins/drum_highway_3d/assets/thumb.svg b/plugins/drum_highway_3d/assets/thumb.svg new file mode 100644 index 0000000..b733073 --- /dev/null +++ b/plugins/drum_highway_3d/assets/thumb.svg @@ -0,0 +1,5 @@ + + + + 3D + diff --git a/plugins/drum_highway_3d/plugin.json b/plugins/drum_highway_3d/plugin.json new file mode 100644 index 0000000..90d3f21 --- /dev/null +++ b/plugins/drum_highway_3d/plugin.json @@ -0,0 +1,39 @@ +{ + "id": "drum_highway_3d", + "name": "3D Drum Highway", + "version": "0.3.0", + "type": "visualization", + "bundled": true, + "script": "screen.js", + "settings": { + "html": "settings.html", + "category": "graphics" + }, + "standards": [ + "capability-pipelines.v1", + "plugin-runtime-idempotent.v1" + ], + "capabilities": { + "midi-input": { + "roles": [ + "requester" + ], + "requests": [ + "discover", + "list-sources", + "select-source", + "open-source", + "close-source" + ], + "mode": "active", + "compatibility": "degrade-noop", + "ownership": "requester-only", + "safety": "sensitive", + "description": "Reads the e-kit/MIDI-pad input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.", + "version": 1 + } + }, + "category": "practice", + "description": "3D note highway for drum charts.", + "icon": "assets/thumb.svg" +} diff --git a/plugins/drum_highway_3d/screen.js b/plugins/drum_highway_3d/screen.js new file mode 100644 index 0000000..3ef0114 --- /dev/null +++ b/plugins/drum_highway_3d/screen.js @@ -0,0 +1,2184 @@ +// 3D Drum Highway — pure visual mockup. +// +// Exploratory sibling of highway_3d. Renders an 8-lane drum highway (7 lanes +// for hand pieces + a full-width kick bar) populated from a hardcoded demo +// pattern that loops indefinitely. No song-data wiring: bundle.notes / +// bundle.chords / bundle.currentTime are ignored. The viz still plugs into +// slopsmith's setRenderer contract so it appears in the player viz picker +// alongside the guitar highway. +// +// Reuses verbatim from highway_3d: Three.js loader, palette arrays, world +// scale (K), fog, lights — so the two highways feel like the same family +// even though they render different geometry. + +(function () { + 'use strict'; + + /* ====================================================================== + * Verbatim from highway_3d — keep these in sync if upstream tweaks them + * ====================================================================== */ + + const THREE_URL = '/static/vendor/three/three.module.min.js'; + const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js'; + + const PALETTES = { + default: [ + 0xff2828, 0xffd400, 0x2080ff, 0xff8020, + 0x30d040, 0xa040ff, 0xff6bd5, 0x6bffe6, + ], + neon: [ + 0xff0030, 0xffe800, 0x0080ff, 0xff8030, + 0x40ff50, 0xb050ff, 0xff40d0, 0x40ffd0, + ], + pastel: [ + 0xe89aa0, 0xefdf90, 0x9adfee, 0xefb898, + 0xa6e0a8, 0xc4a6e0, 0xe0a6c8, 0xa6e0d8, + ], + }; + const PALETTE_IDS = Object.keys(PALETTES); + + const SCALE = 2.25; + const K = SCALE / 300; + + const FOG_COLOR = 0x1a1a2e; + const FOG_START = 200 * K; + const FOG_END = 670 * K; + + let T = null; + let threeLoadPromise = null; + function loadThree() { + if (!threeLoadPromise) { + threeLoadPromise = import(THREE_URL) + .then(mod => { T = mod; return mod; }) + .catch(() => import(THREE_CDN) + .then(mod => { T = mod; return mod; }) + .catch(e => { + console.error('[Drum-Hwy] Three.js load failed:', e); + threeLoadPromise = null; + throw e; + })); + } + return threeLoadPromise; + } + + /* ====================================================================== + * Drum-specific constants + * ====================================================================== */ + + // World scroll speed (units / second). Matches highway_3d so the two + // viz feel like they belong in the same scene. + const TS = 130 * K; + + // Lane geometry. 7 hand lanes + 1 full-width kick bar. + // lane 0: hi-hat (cymbal) + // lane 1: snare (drum) + // lane 2: high tom (drum) + // lane 3: mid tom (drum) + // lane 4: floor tom(drum) + // lane 5: crash (cymbal) + // lane 6: ride (cymbal) + // lane 7: kick (full-width bar) + const LANE_GAP = 12 * K; // X spacing between lane centers + const KICK_COLOR = 0xffa030; // amber regardless of palette + + // Note dimensions (world units). All keyed to K so scale changes + // propagate. + const DISC_R_BASE = 3.6 * K; // drum disc radius + const DISC_H = 1.2 * K; // drum disc thickness + const CYMBAL_R = 3.0 * K; // cymbal gem radius + const CYMBAL_H = 1.6 * K; // cymbal gem height + + // ─── Piece vocabulary (mirrors lib/drums.py PIECES) ────────────── + // Used for kit configuration: user picks which of these pieces they + // own and in what lane order. + const ALL_PIECES = [ + 'kick', + 'snare', 'snare_xstick', + 'hh_closed', 'hh_open', 'hh_pedal', + 'tom_hi', 'tom_mid', 'tom_low', 'tom_floor', + 'crash_l', 'crash_r', 'splash', 'china', + 'ride', 'ride_bell', + ]; + const PIECE_LABELS = { + kick: 'KICK', + snare: 'SNR', + snare_xstick: 'XSTK', + hh_closed: 'HHc', + hh_open: 'HHo', + hh_pedal: 'HHp', + tom_hi: 'TM1', + tom_mid: 'TM2', + tom_low: 'TM3', + tom_floor: 'FT', + crash_l: 'CRl', + crash_r: 'CRr', + splash: 'SPL', + china: 'CHN', + ride: 'RD', + ride_bell: 'BLL', + }; + const PIECE_CATEGORY = { + kick: 'kick', + snare: 'drum', snare_xstick: 'drum', + hh_closed: 'cymbal', hh_open: 'cymbal', hh_pedal: 'cymbal', + tom_hi: 'drum', tom_mid: 'drum', tom_low: 'drum', tom_floor: 'drum', + crash_l: 'cymbal', crash_r: 'cymbal', splash: 'cymbal', china: 'cymbal', + ride: 'cymbal', ride_bell: 'cymbal', + }; + const PIECE_SUBKIND = { + kick: 'kick', + snare: 'snare', snare_xstick: 'snare', + hh_closed: 'hihat', hh_open: 'hihat', hh_pedal: 'hihat', + tom_hi: 'tom', tom_mid: 'tom', tom_low: 'tom', tom_floor: 'tom', + crash_l: 'crash', crash_r: 'crash', splash: 'crash', china: 'crash', + ride: 'ride', ride_bell: 'ride', + }; + // Palette index per piece — chosen so the default 7-lane kit reads + // the same colours as the original hardcoded LANES layout. + const PIECE_PALETTE_IDX = { + kick: 6, + snare: 0, snare_xstick: 0, + hh_closed: 7, hh_open: 7, hh_pedal: 7, + tom_hi: 4, tom_mid: 2, tom_low: 5, tom_floor: 5, + crash_l: 1, crash_r: 1, splash: 1, china: 1, + ride: 3, ride_bell: 3, + }; + + // Default kit — replicates the prior hardcoded 7-piece + kick layout. + // Users without a saved kit see exactly what the mockup originally + // showed. + const DEFAULT_KIT = { + version: 1, + name: 'Default 7-piece', + // Lanes in order (left-to-right on the highway). The kick is a + // special full-width bar at the bottom — its position in this + // list is irrelevant (always rendered as the bar), but it must + // be present for the bar to show. + lanes: [ + { piece: 'hh_closed' }, + { piece: 'snare' }, + { piece: 'tom_hi' }, + { piece: 'tom_mid' }, + { piece: 'tom_floor' }, + { piece: 'crash_l' }, + { piece: 'ride' }, + { piece: 'kick' }, + ], + // Chart piece-id → user's piece-id when the chart has a piece + // the user didn't map. These explicit fallbacks cover the most + // common kit layouts; any unmapped piece is dropped at render time. + fallbacks: { + snare_xstick: 'snare', + hh_open: 'hh_closed', hh_pedal: 'hh_closed', + tom_low: 'tom_mid', + crash_r: 'crash_l', splash: 'crash_l', china: 'crash_l', + ride_bell: 'ride', + }, + }; + + const LS_KIT_CONFIG = 'drum_h3d_kit_v1'; + + // Module-scope mutable lane layout derived from the active kit. Read + // by createFactory's scene-builders. _rebuildLanesFromKit recomputes + // when the kit changes; instances rebuild their scene by calling + // teardown() + initScene() on the same renderer. + let LANES = []; // ordered hand-lane entries + (optionally) kick last + let LANE_COUNT = 0; // count of HAND lanes (excludes the kick bar) + let LANE_X0 = 0; + let KICK_W = 0; + + function _validateKit(raw) { + // Light validation — accept anything that has lanes[] with + // pieces in ALL_PIECES. Reject malformed input rather than + // letting a bad config crash initScene. + if (!raw || typeof raw !== 'object') return null; + const lanes = Array.isArray(raw.lanes) ? raw.lanes : null; + if (!lanes) return null; + const cleanLanes = []; + const seenPieces = new Set(); + for (const ln of lanes) { + if (!ln || typeof ln.piece !== 'string') continue; + if (!ALL_PIECES.includes(ln.piece)) continue; + if (seenPieces.has(ln.piece)) continue; + seenPieces.add(ln.piece); + cleanLanes.push({ piece: ln.piece }); + } + if (cleanLanes.length === 0) return null; + const fb = (raw.fallbacks && typeof raw.fallbacks === 'object') ? raw.fallbacks : {}; + const cleanFb = {}; + for (const [from, to] of Object.entries(fb)) { + if (!ALL_PIECES.includes(from) || !ALL_PIECES.includes(to)) continue; + if (!seenPieces.has(to)) continue; + cleanFb[from] = to; + } + return { + version: 1, + name: typeof raw.name === 'string' ? raw.name.slice(0, 80) : 'Custom kit', + lanes: cleanLanes, + fallbacks: cleanFb, + }; + } + + function _readKitConfig() { + try { + const raw = localStorage.getItem(LS_KIT_CONFIG); + if (!raw) return null; + return _validateKit(JSON.parse(raw)); + } catch (_) { return null; } + } + + function _writeKitConfig(kit) { + try { localStorage.setItem(LS_KIT_CONFIG, JSON.stringify(kit)); } catch (_) {} + } + + // The active kit object — replaceable at runtime via setKit(). + let _activeKit = _readKitConfig() || DEFAULT_KIT; + + function _rebuildLanesFromKit(kit) { + // Split: hand lanes first (in the user's chosen order), kick + // last (if present). This matches the original 8-entry LANES + // shape so all downstream geometry code keeps working. + const handLanes = []; + let kickLane = null; + for (const ln of kit.lanes) { + const piece = ln.piece; + const entry = { + kind: PIECE_CATEGORY[piece] || 'drum', + subKind: PIECE_SUBKIND[piece] || 'tom', + label: PIECE_LABELS[piece] || piece.toUpperCase(), + paletteIdx: PIECE_PALETTE_IDX[piece] || 0, + piece, + }; + if (entry.kind === 'kick') kickLane = entry; + else handLanes.push(entry); + } + LANES = kickLane ? handLanes.concat([kickLane]) : handLanes.slice(); + LANE_COUNT = handLanes.length; + LANE_X0 = -((LANE_COUNT - 1) / 2) * LANE_GAP; + KICK_W = Math.max(1, LANE_COUNT) * LANE_GAP + 2 * K; + } + _rebuildLanesFromKit(_activeKit); + const KICK_H = 1.4 * K; // kick bar thickness + const KICK_D = 4.0 * K; // kick bar depth (along scroll) + + // Variant size multipliers. + const GHOST_SCALE = 0.65; + const ACCENT_SCALE = 1.25; + const FLAM_GRACE_OFFSET = 0.08; // seconds before main note + const FLAM_GRACE_SCALE = 0.55; + + // How many seconds of upcoming notes are visible at once. + const AHEAD = 3.2; + const BEHIND = 0.4; + + // Map drum_tab piece-ids (lib/drums.py PIECES) → lane index in the + // active user kit's LANES. Rebuilt by _rebuildPieceToLaneMap whenever + // the kit changes. Pieces the user doesn't have route through + // _activeKit.fallbacks; pieces with no direct or fallback mapping get + // `undefined` and the hit is dropped at render time. + let PIECE_TO_LANE = {}; + function _rebuildPieceToLaneMap(kit) { + PIECE_TO_LANE = {}; + for (let i = 0; i < LANES.length; i++) { + const piece = LANES[i].piece; + if (piece) PIECE_TO_LANE[piece] = i; + } + // Apply user-defined fallbacks to cover chart pieces the user + // doesn't have on their kit (e.g. chart has tom_low + tom_floor, + // user has only one floor tom → fallback maps low onto floor). + const fb = (kit && kit.fallbacks) || {}; + for (const piece of ALL_PIECES) { + if (PIECE_TO_LANE[piece] !== undefined) continue; + const target = fb[piece]; + if (target && PIECE_TO_LANE[target] !== undefined) { + PIECE_TO_LANE[piece] = PIECE_TO_LANE[target]; + } + } + } + _rebuildPieceToLaneMap(_activeKit); + + // Resolve a drum_tab hit's visual variant. Order matters: ghost wins over + // accent (ghost notes are intentionally quiet so their flag dominates), + // flam adds the leading grace disc, ride_bell adds the bright dot. A + // loud non-ghost hit (v >= 100) is an accent. + function _variantForHit(hit) { + if (hit.g) return 'ghost'; + if (hit.f) return 'flam'; + if (hit.p === 'ride_bell') return 'bell'; + const v = typeof hit.v === 'number' ? hit.v : 100; + if (v >= 100) return 'accent'; + return 'normal'; + } + + /* ====================================================================== + * MIDI input + hit detection (module-scope singletons) + * ====================================================================== */ + + // Reverse of lib/drums.py PIECES default midis. The 2D drums plugin keeps + // a user-editable mapping in localStorage; the 3D viz starts with this + // baseline and the settings UI can override later. Multiple MIDI notes + // can map to the same piece-id (e.g. 35 & 36 both → kick). + const MIDI_TO_PIECE = { + 35: 'kick', 36: 'kick', + 37: 'snare_xstick', + 38: 'snare', 40: 'snare', + 41: 'tom_floor', + 42: 'hh_closed', + 43: 'tom_low', 58: 'tom_low', + 44: 'hh_pedal', + 45: 'tom_mid', 47: 'tom_mid', + 46: 'hh_open', + 48: 'tom_hi', 50: 'tom_hi', + 49: 'crash_l', + 51: 'ride', 59: 'ride', + 52: 'china', + 53: 'ride_bell', + 55: 'splash', + 57: 'crash_r', + }; + + // ±50 ms hit window — same as the 2D drums plugin so users get + // identical timing across both visualisations. + const HIT_TOLERANCE_S = 0.05; + // Legacy id-only storage key. Read for migration; new writes go to + // LS_MIDI_PICK (id + name). + const LS_MIDI_INPUT = 'drum_h3d_midi_input'; + const LS_MIDI_PICK = 'drum_h3d_midi_pick_v2'; + + // Inputs whose name matches this regex are skipped from the auto-pick + // fallback. They're either passthrough loopbacks (Midi Through) or + // audio interfaces that happen to expose a MIDI port but don't have + // a controller behind them. + const _MIDI_BLOCKLIST_RE = /midi through|^thru\b|^iac\b/i; + + // MIDI is sourced from the core `midi-input` capability domain + // (window.slopsmith.midiInput) rather than a private requestMIDIAccess() — + // one device-access boundary shared with piano/drums/keys/onboarding. + const _MIDI_REQUESTER = 'drum_highway_3d'; + let _midiReady = false; // discover() has run + let _midiHandle = null; // live domain session handle (addListener/removeListener) + let _midiListener = null; // addListener callback wrapping _midiOnMessage + let _midiStateSub = false; // subscribed to midi-input:sources-changed + let _midiInput = null; // selected source descriptor { id, name } + // Set to true by _midiConnect when a new device is selected. The render + // loop reads and clears this to skip retroactive miss-counting for notes + // that elapsed while no device was connected. + let _midiJustConnected = false; + // Gates the live listener wiring. init() flips true via _midiResume() and + // the last destroy() flips false via _midiReleaseSession(). Necessary because _midiConnect is + // async — an open() can resolve after the renderer was destroyed, and + // without this gate addListener would wire hits into a dead instance. + let _midiActive = false; + // Routes incoming MIDI events to the focused renderer (null when no + // instance is active). Splitscreen support is deferred; for now this + // tracks the singleton instance. + let _activeInstance = null; + const _instances = new Set(); + + function _readStore(k) { + try { return localStorage.getItem(k); } catch (_) { return null; } + } + function _writeStore(k, v) { + try { localStorage.setItem(k, v); } catch (_) {} + } + + // The core midi-input domain, if present (it ships with core). + function _mi() { + const m = window.slopsmith && window.slopsmith.midiInput; + return (m && m.version === 1) ? m : null; + } + // Domain sources shaped like the old MIDIInput list: { id, name }. + // sourceId == the old MIDIInput.id, so saved picks stay compatible. + function _midiSources() { + const mi = _mi(); + if (!mi) return []; + return mi.listSources().map(s => ({ id: s.sourceId, name: s.label, key: s.logicalSourceKey })); + } + // Detach the live listener + release the domain session. + function _midiDetach() { + // Invalidate any in-flight _midiConnect open: a detach driven by device + // removal (sources-changed) or an opt-out must supersede a pending open + // so it can't resume and install a handle for a now-gone source. + _midiConnectSeq += 1; + if (_midiHandle && _midiListener) { try { _midiHandle.removeListener(_midiListener); } catch (_) { /* best-effort */ } } + const mi = _mi(); + if (mi && _midiInput) { try { mi.close({ requester: _MIDI_REQUESTER, logicalSourceKey: _midiInput.key || ('web-midi::' + _midiInput.id) }); } catch (_) { /* best-effort */ } } + _midiHandle = null; + _midiListener = null; + _midiInput = null; + } + + let _midiInitInFlight = null; + let _midiConnectSeq = 0; // generation guard for async _midiConnect races + async function _midiInit() { + if (_midiReady) { + // Only (re)connect when there's no live session. A repeated init + // (settings panel open, extra splitscreen instance) must NOT re-enter + // _midiConnect on an active handle — that tears down the live session + // and releases held pads for no reason. After a full release the + // handle is null, so reconnect happens then. + if (!_midiHandle) _midiAutoConnect(); + return; + } + if (_midiInitInFlight) return _midiInitInFlight; + const mi = _mi(); + if (!mi) return; + _midiInitInFlight = (async () => { + try { + const r = await mi.discover(); // permission boundary (requestMIDIAccess, in core) + // Only latch ready on a successful discovery — a denied/unavailable + // outcome must NOT latch, or reopening never retries the prompt. + if (!r || r.outcome !== 'handled') return; + _midiReady = true; + // Replug/unplug refresh (replaces MIDIAccess.onstatechange). + if (!_midiStateSub && window.slopsmith && typeof window.slopsmith.on === 'function') { + _midiStateSub = true; + window.slopsmith.on('midi-input:sources-changed', () => { + _midiNotifyDeviceListChanged(); + // Reconnect the saved device when it (re)appears after an + // unplug; recovery only (no fallback) so a transient unplug + // doesn't switch to / persist another input. + if (!_midiInput) _midiAutoConnect(false); + }); + } + _midiAutoConnect(); + _midiNotifyDeviceListChanged(); + } catch (e) { + console.warn('[Drum-Hwy3D] MIDI access denied:', e); + } finally { + _midiInitInFlight = null; + } + })(); + return _midiInitInFlight; + } + + function _readSavedPick() { + // New v2 storage: {id, name} JSON. Falls back to legacy v1 id-only. + // Coerce id/name to strings so _midiAutoConnect can safely call + // .toLowerCase() even if the stored JSON was manually edited. + try { + const v2 = _readStore(LS_MIDI_PICK); + if (v2) { + const obj = JSON.parse(v2); + if (obj && typeof obj === 'object') { + return { id: String(obj.id || ''), name: String(obj.name || ''), key: String(obj.key || '') }; + } + } + } catch (_) {} + const v1 = _readStore(LS_MIDI_INPUT); + if (typeof v1 === 'string') return { id: v1, name: '' }; + return null; + } + + function _writeSavedPick(id, name, key) { + try { + localStorage.setItem(LS_MIDI_PICK, JSON.stringify({ id: id || '', name: name || '', key: key || '' })); + // Keep the legacy key in sync so a downgrade is non-destructive. + localStorage.setItem(LS_MIDI_INPUT, id || ''); + } catch (_) {} + } + + function _midiAutoConnect(allowFallback) { + // Recovery (sources-changed after unplug) passes false: never switch to a + // fallback input, because _midiConnect persists the pick and that would + // overwrite the user's saved device on a transient multi-device unplug + // (the original returns on replug and reconnects then). + if (allowFallback === undefined) allowFallback = true; + const inputs = _midiSources(); + if (!inputs.length) return; + const saved = _readSavedPick(); + // Explicit None — user opted out, don't auto-connect. + if (saved && saved.id === '' && saved.name === '') return; + // Prefer exact id match (browser kept the id stable). Fall back + // to case-insensitive name match — Chrome+Linux regenerates the + // hashed id on every page load, so id-only matching strands the + // saved pick after the first reload. + let target = null; + // Prefer the globally-unique logicalSourceKey, then the legacy sourceId. + if (saved && saved.key) { + target = inputs.find(i => i.key === saved.key) || null; + } + if (!target && saved && saved.id) { + target = inputs.find(i => i.id === saved.id) || null; + } + if (!target && saved && saved.name) { + const n = saved.name.toLowerCase(); + target = inputs.find(i => (i.name || '').toLowerCase() === n) || null; + } + if (!target) { + // Skip the substitute ONLY when a saved pick exists but is currently + // absent (recovery: preserve it, don't clobber on a transient unplug). + // With no saved pick at all, a fallback is the intended first-hotplug + // auto-connect — allow it even in recovery. + const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name)); + if (!allowFallback && hasSavedPick) return; + // Pick the first input that isn't a loopback/passthrough. Falls back to + // inputs[0] only if every input is blocklisted. + target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0]; + } + _midiConnect(target.id, target.name, target.key); + } + + async function _midiConnect(id, name, key) { + // Capture our generation AFTER _midiDetach()'s own bump, so a later + // detach (device removal / new connect / opt-out) reliably supersedes us. + _midiDetach(); + const myGen = ++_midiConnectSeq; + // Pass through `name` so a future id-drift can still find this + // device. Empty id is the explicit-None opt-out. + _writeSavedPick(id || '', name || '', key || ''); + const mi = _mi(); + if ((id || key) && mi) { + // Prefer the globally-unique logicalSourceKey so two providers that + // expose the same provider-local sourceId stay distinguishable; fall + // back to the legacy sourceId match. + const src = (key && _midiSources().find(s => s.key === key)) + || (id && _midiSources().find(s => s.id === id)) + || null; + if (src) { + const lkey = src.key || ('web-midi::' + src.id); + _midiInput = { id: src.id, name: src.name, key: lkey }; + _midiJustConnected = true; + // No live renderer to consume OR release a session — don't hold one + // open (settings-only ensure-init, or the last instance was torn + // down during async discovery). The pick is saved; a later renderer + // mount re-runs auto-connect and opens for real, releasing on destroy. + if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; } + try { + await mi.select(lkey); + const res = await mi.open({ requester: _MIDI_REQUESTER, logicalSourceKey: lkey }); + // A newer _midiConnect (device switch / None / replug) ran while + // we awaited open — discard this stale session so we don't wire a + // listener for a device the user already moved off of. + if (myGen !== _midiConnectSeq) { + if (!_midiInput || _midiInput.key !== lkey) { try { mi.close({ requester: _MIDI_REQUESTER, logicalSourceKey: lkey }); } catch (_) { /* best-effort */ } } + return; + } + if (res && res.handle) { + _midiHandle = res.handle; + // The domain handle delivers raw MIDI data; adapt to the + // old MIDIMessageEvent shape so _midiOnMessage is unchanged. + _midiListener = (data) => _midiOnMessage({ data }); + if (_midiActive) _midiHandle.addListener(_midiListener); + } else { + // Open yielded no live handle (device vanished post-discovery, + // or denied/unavailable). Clear the selection so the render + // loop's connected-device gate doesn't sweep phantom misses. + _midiInput = null; + } + } catch (e) { + console.warn('[Drum-Hwy3D] MIDI open failed:', e); + // Only clear if we're still the current connect — a stale older + // open's rejection must not wipe a newer connect's installed + // _midiInput/_midiHandle (which would also leak the live handle, + // since closes are gated on _midiInput). + if (myGen === _midiConnectSeq) _midiInput = null; + } + } + } + // Notify on BOTH connect and disconnect/opt-out so any open + // settings panel re-renders its device dropdown consistently. + _midiNotifyDeviceListChanged(); + } + + function _midiResume() { + // Idempotent: a second live renderer instance (splitscreen/overlapping + // lifetimes) calls this while already active. The domain handle's + // addListener is Set-backed, but don't rely on the provider de-duping — + // re-adding here could double-deliver one MIDI hit to the focused + // instance and score a hit plus duplicate misses. + if (_midiActive) return; + _midiActive = true; + if (_midiHandle && _midiListener) { try { _midiHandle.addListener(_midiListener); } catch (_) { /* best-effort */ } } + } + // Called when the LAST live instance is torn down: fully release the shared + // midi-input domain session (via _midiDetach: close + null + generation bump), + // not just the listener, so the device/provider session isn't held open after + // the visualization is gone. Re-mount's _midiInit auto-connects from the saved + // pick, so _midiReady is intentionally left latched. + function _midiReleaseSession() { + _midiActive = false; + _midiDetach(); + } + + function _midiOnMessage(e) { + if (!_activeInstance) return; + const data = e.data; + if (!data || data.length < 3) return; + const type = data[0] & 0xf0; + const note = data[1]; + const vel = data[2]; + // 0x90 = note-on. note-on with velocity 0 is a note-off (running + // status); skip those too. + if (type !== 0x90 || vel === 0) return; + _activeInstance._handleDrumHit(note, vel); + } + + // Fired when the MIDI access state changes (device plugged/unplugged + // or a connect/disconnect from _midiConnect). Settings panels listen + // via the global event so they can re-render their device dropdown. + function _midiNotifyDeviceListChanged() { + // If the currently-selected input was disconnected, clear the reference + // so _updateMissed() stops counting and the UI shows "no device". + if (_midiInput && !_midiSources().some(s => s.id === _midiInput.id)) { + _midiDetach(); + } + try { + window.dispatchEvent(new CustomEvent('drum_h3d:midi_devices')); + } catch (_) {} + } + + // List the currently-known input devices for the settings UI. + function _midiListInputs() { + return _midiSources().map(s => ({ id: s.id, name: s.name || s.id })); + } + function _midiCurrentInputId() { + return _midiInput ? _midiInput.id : ''; + } + + /* ── Kit configuration API for the settings panel ──────────────── */ + + window.drumH3dGetKit = function () { + // Returns a deep clone so the settings UI can mutate without + // disturbing the live config. + return JSON.parse(JSON.stringify(_activeKit)); + }; + window.drumH3dGetAllPieces = function () { + return ALL_PIECES.slice(); + }; + window.drumH3dGetPieceLabels = function () { + return Object.assign({}, PIECE_LABELS); + }; + window.drumH3dGetPieceCategory = function () { + return Object.assign({}, PIECE_CATEGORY); + }; + // Exposed so settings.html palette swatches stay in sync with the + // renderer without duplicating the mapping. kick maps to -1 here + // (settings.html convention = render amber, matching the renderer's + // special-case amber for kick bars). + window.drumH3dGetPiecePaletteIdx = function () { + return Object.assign({}, PIECE_PALETTE_IDX, { kick: -1 }); + }; + window.drumH3dSetKit = function (raw) { + const kit = _validateKit(raw); + if (!kit) return false; + _activeKit = kit; + _writeKitConfig(kit); + _rebuildLanesFromKit(kit); + _rebuildPieceToLaneMap(kit); + // Notify renderers + settings panels — each instance rebuilds its + // scene; settings panels re-render to reflect the new kit. + try { + window.dispatchEvent(new CustomEvent('drum_h3d:kit', { detail: { kit } })); + } catch (_) {} + return true; + }; + window.drumH3dResetKit = function () { + window.drumH3dSetKit(DEFAULT_KIT); + }; + // Name-only update — does NOT dispatch 'drum_h3d:kit' (which triggers a + // full WebGL scene teardown/reinit). Typing in the kit-name field therefore + // causes zero GPU overhead; the new name is persisted immediately so it + // survives export/clipboard share on the same keystroke. + window.drumH3dSetKitName = function (name) { + const trimmed = String(name || '').slice(0, 80); + if (!_activeKit) return; + _activeKit.name = trimmed; + _writeKitConfig(_activeKit); + }; + // base64(JSON) round-trip for sharing kits between users. + // Use TextEncoder/TextDecoder for UTF-8-safe base64 (escape/unescape are + // deprecated and can mis-handle non-ASCII characters in kit names). + window.drumH3dExportKit = function () { + const bytes = new TextEncoder().encode(JSON.stringify(_activeKit)); + return btoa(String.fromCharCode(...bytes)); + }; + window.drumH3dImportKit = function (b64) { + try { + const binStr = atob((b64 || '').trim()); + const bytes = Uint8Array.from(binStr, c => c.charCodeAt(0)); + const obj = JSON.parse(new TextDecoder().decode(bytes)); + return window.drumH3dSetKit(obj); + } catch (_) { return false; } + }; + + /* ── MIDI device control API (consumed by settings.html) ───── */ + window.drumH3dEnsureMidiInit = function () { + // Settings panel calls this on mount so the user can see + pick + // MIDI devices without having to load a song first. Fires the + // browser permission prompt the first time; idempotent after. + // Returns a promise that resolves when _midiInit settles. + return _midiInit(); + }; + window.drumH3dListMidiInputs = function () { + // Returns [{id, name}, ...] of all currently-known inputs, in + // whatever order the browser enumerates them. Settings panel + // re-renders on the 'drum_h3d:midi_devices' event. + return _midiListInputs(); + }; + window.drumH3dGetMidiInputId = function () { + return _midiCurrentInputId(); + }; + window.drumH3dSetMidiInput = function (id) { + // Empty string = explicit "None" opt-out. Persists by name + + // id via _midiConnect so a future page reload still finds the + // device after Chrome regenerates ids. + // `id` may be a logicalSourceKey (new host calls) or a legacy sourceId. + const src = id + ? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id)) + : null; + _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : ''); + return true; + }; + window.drumH3dGetSynthVolume = function () { + // When the synth has not initialised yet (viz never ran, settings + // opened first), read from localStorage so the settings slider shows + // the persisted value rather than the default 0.70. + if (!_synthPlayer) { + const raw = _readStore(LS_SYNTH_VOL); + const parsed = raw === null ? NaN : parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) return parsed; + } + return _synthVolume; + }; + window.drumH3dSetSynthVolume = function (v) { + _synthSetVolume(v); + }; + + /* ====================================================================== + * WebAudioFont drum-kit synth (module-scope, one AudioContext per tab) + * + * Ported verbatim from slopsmith-plugin-drums so the 3D viz makes the + * same kit sounds when the user strikes a pad. Preset URLs and the + * JCLive soundfont match the 2D plugin so the kit feels identical. + * Volume defaults to 0.7 and persists via `drum_h3d_synth_vol`. + * ====================================================================== */ + + const WAF_PLAYER_URL = 'https://surikov.github.io/webaudiofont/npm/dist/WebAudioFontPlayer.js'; + const WAF_BASE = 'https://surikov.github.io/webaudiofontdata/sound/'; + const WAF_SF = 'JCLive_sf2_file'; + // GM percussion notes the JCLive soundfont actually maps. Matches the + // 2D plugin's DRUM_MIDI_NOTES; adding entries here would just download + // 404s if the soundfont doesn't ship them. + const DRUM_MIDI_NOTES = [35, 36, 38, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 57, 59]; + const LS_SYNTH_VOL = 'drum_h3d_synth_vol'; + + let _audioCtx = null; + let _synthPlayer = null; + let _synthGain = null; + let _synthVolume = 0.7; + let _playerScriptLoaded = false; + let _synthInitInFlight = null; // shared promise while _synthInit is running + const _drumPresets = {}; // midiNote -> WebAudioFont preset + + function _loadScript(url) { + return new Promise((resolve, reject) => { + // Reuse an existing tag only if it succeeded (no data-error). + // A previously-failed tag must be removed and retried so we don't + // resolve immediately with a script that never actually executed. + // An in-flight tag (no data-loaded / data-error yet) gets + // additional listeners so we wait for its outcome rather than + // resolving immediately before it has actually run. + const existing = document.querySelector(`script[src="${url}"]`); + if (existing) { + if (existing.dataset.error) { + // Previous load failed — remove and retry. + existing.remove(); + } else if (existing.dataset.loaded) { + // Already fully executed. + resolve(); + return; + } else { + // Still in flight — piggyback on the same tag. + existing.addEventListener('load', () => { existing.dataset.loaded = '1'; resolve(); }, { once: true }); + existing.addEventListener('error', () => { existing.dataset.error = '1'; reject(new Error('Failed to load ' + url)); }, { once: true }); + return; + } + } + const s = document.createElement('script'); + s.src = url; + s.onload = () => { s.dataset.loaded = '1'; resolve(); }; + s.onerror = () => { s.dataset.error = '1'; reject(new Error('Failed to load ' + url)); }; + document.head.appendChild(s); + }); + } + + function _wafVar(note) { return '_drum_' + note + '_0_' + WAF_SF; } + function _wafUrl(note) { return WAF_BASE + '128' + note + '_0_' + WAF_SF + '.js'; } + + async function _synthInit() { + if (_synthPlayer) return; + // Guard against concurrent calls: if a prior call is still awaiting + // script/preset loads, share its promise so only one AudioContext and + // one WebAudioFontPlayer are ever created per tab. + if (_synthInitInFlight) return _synthInitInFlight; + _synthInitInFlight = (async () => { + try { + const raw = _readStore(LS_SYNTH_VOL); + const parsed = raw === null ? NaN : parseFloat(raw); + if (Number.isFinite(parsed) && parsed >= 0 && parsed <= 1) _synthVolume = parsed; + if (!_playerScriptLoaded) { + await _loadScript(WAF_PLAYER_URL); + _playerScriptLoaded = true; + } + if (typeof WebAudioFontPlayer === 'undefined') return; + _audioCtx = new (window.AudioContext || window.webkitAudioContext)(); + _synthGain = _audioCtx.createGain(); + _synthGain.gain.value = _synthVolume; + _synthGain.connect(_audioCtx.destination); + _synthPlayer = new WebAudioFontPlayer(); + // Chrome creates AudioContext in suspended state and refuses to + // play scheduled audio until a user gesture resumes it. MIDI + // note-on events count as gestures in recent Chrome — but only + // if the gesture-to-resume gap is small. Arm a one-shot + // pointerdown / keydown listener so the very first user + // interaction anywhere on the page unblocks playback. The + // {once:true} flag detaches the listener after it fires. + const _resumeOnGesture = () => { + if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume(); + }; + window.addEventListener('pointerdown', _resumeOnGesture, { once: true, capture: true }); + window.addEventListener('keydown', _resumeOnGesture, { once: true, capture: true }); + await _synthLoadKit(); + } catch (e) { + console.warn('[Drum-Hwy3D] Synth init failed:', e); + } finally { + _synthInitInFlight = null; + } + })(); + return _synthInitInFlight; + } + + async function _synthLoadKit() { + if (!_synthPlayer || !_audioCtx) return; + await Promise.all(DRUM_MIDI_NOTES.map(async (note) => { + const varName = _wafVar(note); + try { + if (!window[varName]) await _loadScript(_wafUrl(note)); + const preset = window[varName]; + if (preset) { + _synthPlayer.adjustPreset(_audioCtx, preset); + _drumPresets[note] = preset; + } + } catch (e) { + console.warn('[Drum-Hwy3D] preset load failed for MIDI ' + note + ':', e); + } + })); + } + + function _synthEnsureCtx() { + // Most browsers block AudioContext until a user gesture. MIDI input + // counts as a gesture in Chrome — _synthDrumHit kicks the context + // out of suspended on first hit. + if (_audioCtx && _audioCtx.state === 'suspended') _audioCtx.resume(); + } + + function _synthDrumHit(midiNote, velocity) { + if (!_synthPlayer || !_audioCtx || !_synthGain) return; + const preset = _drumPresets[midiNote]; + if (!preset) return; + _synthEnsureCtx(); + // Velocity-to-volume: normalise to 0-1. The overall slider level is + // already applied by _synthGain (its gain.value == _synthVolume), so + // don't multiply here — that would square the volume. + const vol = (velocity || 100) / 127; + // 0.5 s queue duration — drum samples are short, no sustain needed. + _synthPlayer.queueWaveTable(_audioCtx, _synthGain, preset, 0, midiNote, 0.5, vol); + } + + function _synthSetVolume(v) { + const c = Math.max(0, Math.min(1, Number(v) || 0)); + _synthVolume = c; + if (_synthGain) _synthGain.gain.value = c; + _writeStore(LS_SYNTH_VOL, String(c)); + } + + /* ====================================================================== + * Demo patterns — hardcoded, loop indefinitely + * ====================================================================== */ + + // Each pattern: { length: seconds, notes: [{ t, lane, variant? }] } + // lane is the LANES index. variant is optional and recognised values are + // 'accent' | 'ghost' | 'flam' | 'bell' (bell only meaningful on ride lane). + // + // Times are in seconds within the loop. The renderer schedules each note + // at every loop cycle (t, t+length, t+2*length, …) within the active + // window, so notes recycle naturally without bookkeeping. + + const DEMO_PATTERNS = { + // Classic rock backbeat: kick on 1+3, snare on 2+4, hi-hat 8ths, + // crash on the downbeat of bar 1. 4-bar loop at ~120 BPM (2s/bar). + rock_backbeat: { + length: 8.0, + notes: (() => { + const out = []; + for (let bar = 0; bar < 4; bar++) { + const b = bar * 2.0; + // hi-hat 8ths + for (let i = 0; i < 8; i++) out.push({ t: b + i * 0.25, lane: 0 }); + // kick on 1 and 3 + out.push({ t: b + 0.0, lane: 7 }); + out.push({ t: b + 1.0, lane: 7 }); + // snare on 2 and 4 + out.push({ t: b + 0.5, lane: 1 }); + out.push({ t: b + 1.5, lane: 1 }); + // crash on bar-1 downbeat + if (bar === 0) out.push({ t: b + 0.0, lane: 5, variant: 'accent' }); + } + return out; + })(), + }, + + // Jazz swing — ride pattern, ghost-note snare comping, soft kick. + jazz_swing: { + length: 8.0, + notes: (() => { + const out = []; + for (let bar = 0; bar < 4; bar++) { + const b = bar * 2.0; + // ride: ding ding-a ding ding-a (swing 8ths) + for (let beat = 0; beat < 4; beat++) { + const onBeat = b + beat * 0.5; + out.push({ t: onBeat, lane: 6, variant: beat === 0 ? 'bell' : undefined }); + // swing "and" — closer to the next beat + if (beat === 1 || beat === 3) { + out.push({ t: onBeat + 0.33, lane: 6 }); + } + } + // hi-hat foot on 2 and 4 + out.push({ t: b + 0.5, lane: 0 }); + out.push({ t: b + 1.5, lane: 0 }); + // snare ghost comping + out.push({ t: b + 0.75, lane: 1, variant: 'ghost' }); + out.push({ t: b + 1.25, lane: 1, variant: 'ghost' }); + // soft kick on 1 + out.push({ t: b + 0.0, lane: 7 }); + } + return out; + })(), + }, + + // Showcase every variant: ghost, accent, flam, bell — plus every + // lane fires at least once. Designed to read each visual element + // clearly without flipping settings. + fill_showcase: { + length: 8.0, + notes: [ + // Bar 1 — basic groove to set the stage + { t: 0.00, lane: 7 }, // kick + { t: 0.00, lane: 0 }, // hh + { t: 0.00, lane: 5, variant: 'accent' }, // crash accent (lane 5) + { t: 0.25, lane: 0 }, + { t: 0.50, lane: 1 }, // snare + { t: 0.50, lane: 0 }, + { t: 0.75, lane: 0 }, + { t: 1.00, lane: 7 }, + { t: 1.00, lane: 0 }, + { t: 1.25, lane: 0 }, + { t: 1.50, lane: 1 }, + { t: 1.50, lane: 0 }, + { t: 1.75, lane: 0 }, + // Bar 2 — ghost note showcase on snare + { t: 2.00, lane: 7 }, + { t: 2.00, lane: 0 }, + { t: 2.125, lane: 1, variant: 'ghost' }, + { t: 2.25, lane: 0 }, + { t: 2.375, lane: 1, variant: 'ghost' }, + { t: 2.50, lane: 1 }, + { t: 2.50, lane: 0 }, + { t: 2.625, lane: 1, variant: 'ghost' }, + { t: 2.75, lane: 0 }, + { t: 3.00, lane: 7 }, + { t: 3.50, lane: 1, variant: 'accent' }, // snare accent + // Bar 3 — flam showcase + ride + { t: 4.00, lane: 7 }, + { t: 4.00, lane: 6 }, // ride + { t: 4.50, lane: 1, variant: 'flam' }, // snare flam + { t: 5.00, lane: 6, variant: 'bell' }, // ride bell + { t: 5.50, lane: 1, variant: 'flam' }, // snare flam + // Bar 4 — tom roll down the kit + { t: 6.00, lane: 7 }, + { t: 6.00, lane: 2 }, // hi tom + { t: 6.25, lane: 2 }, + { t: 6.50, lane: 3 }, // mid tom + { t: 6.75, lane: 3 }, + { t: 7.00, lane: 4 }, // floor tom + { t: 7.25, lane: 4 }, + { t: 7.50, lane: 5, variant: 'accent' }, // crash accent + { t: 7.50, lane: 7 }, // kick under crash + ], + }, + }; + + /* ====================================================================== + * Settings hydration — palette/pattern/camera-angle live in localStorage. + * ====================================================================== */ + + const LS_KEYS = { + palette: 'drum_h3d_palette', + pattern: 'drum_h3d_pattern', + cameraAngle: 'drum_h3d_camera_angle', + }; + + function readSettings() { + let palette = 'default'; + let pattern = 'rock_backbeat'; + let cameraAngle = 0.35; // 0 = looking down the lanes, 1 = top-down + try { + const p = localStorage.getItem(LS_KEYS.palette); + if (p && PALETTES[p]) palette = p; + const pat = localStorage.getItem(LS_KEYS.pattern); + if (pat && DEMO_PATTERNS[pat]) pattern = pat; + const ca = parseFloat(localStorage.getItem(LS_KEYS.cameraAngle)); + if (Number.isFinite(ca)) cameraAngle = Math.min(1, Math.max(0, ca)); + } catch (_) { /* localStorage unavailable — use defaults */ } + return { palette, pattern, cameraAngle }; + } + + // Expose setters so settings.html can poke a live preview without a + // reload. Setters update localStorage and broadcast a CustomEvent that + // the renderer subscribes to. + window.drumH3dSetPalette = function (id) { + if (!PALETTES[id]) return; + try { localStorage.setItem(LS_KEYS.palette, id); } catch (_) {} + window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { palette: id } })); + }; + window.drumH3dSetPattern = function (id) { + if (!DEMO_PATTERNS[id]) return; + try { localStorage.setItem(LS_KEYS.pattern, id); } catch (_) {} + window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { pattern: id } })); + }; + window.drumH3dSetCameraAngle = function (v) { + const c = Math.min(1, Math.max(0, Number(v) || 0)); + try { localStorage.setItem(LS_KEYS.cameraAngle, String(c)); } catch (_) {} + window.dispatchEvent(new CustomEvent('drum_h3d:settings', { detail: { cameraAngle: c } })); + }; + + /* ====================================================================== + * Renderer factory + * ====================================================================== */ + + function createFactory() { + // Per-instance state (per-panel under splitscreen). + let highwayCanvas = null; + let scene = null; + let cam = null; + let ren = null; + let lights = null; + + // Settings snapshot — mutated by 'drum_h3d:settings' event. + let settings = readSettings(); + let activePalette = PALETTES[settings.palette]; + + // Scene groups / pooled meshes. + let laneGroup = null; // lane stripes + dividers + let kitMapGroup = null; // top-of-highway kit silhouette + let notesGroup = null; // all currently-visible notes (recreated each frame) + + // Cached materials — palette-driven, rebuilt on palette swap. + let mDrumByLane = null; // Mesh material per lane (drum lanes) + let mCymbalByLane = null; // Mesh material per lane (cymbal lanes) + // Pre-cloned hit/miss tint materials — avoids per-frame per-note clones. + let mDrumHitByLane = null; + let mDrumMissByLane = null; + let mCymbalHitByLane = null; + let mCymbalMissByLane = null; + let mKick = null; // Kick bar material + let mKickHit = null; // Pre-cloned hit tint for kick + let mKickMiss = null; // Pre-cloned miss tint for kick + let mAccentRing = null; // Halo material for accents + let mGhostRing = null; // Hollow ring material for ghost notes + let mSnareStripe = null; // White snare wire material + let mBellDot = null; // Bright dot for ride bell hits + + // Geometry — shared across notes. + let gDrumDisc = null; + let gCymbalGem = null; + let gKickBar = null; + let gAccentRing = null; + let gGhostRing = null; + let gSnareStripe = null; + let gBellDot = null; + let gFlamGrace = null; + + // Demo loop clock — anchored to performance.now() so animation + // proceeds regardless of audio playback state. + const t0 = performance.now() / 1000; + + let _isReady = false; + let _settingsHandler = null; + let _kitHandler = null; + + /* ── Hit detection + scoring (per-instance) ──────────────── */ + // _hitKeys / _missedKeys: note keys (t|lane) of drum_tab hits the + // user already scored or scrolled past. Keying on LANE (not piece) + // lets a hit on either crash MIDI register against either crash + // note on the shared CR lane — same semantics as the 2D plugin. + const _hitKeys = new Set(); + const _missedKeys = new Set(); + let _hits = 0, _misses = 0; + let _streak = 0, _bestStreak = 0; + // [{lane, wall, kind}] — wall is performance.now(); kind drives + // the colour of the lane flash (green=hit, red=miss/wrong). + const _laneFlashes = []; + // Latest snapshot of (sorted real-data notes, currentTime) so + // MIDI events (which fire async w.r.t. draw) score against the + // chart the user is actually looking at. + let _latestNotes = null; + let _latestTime = 0; + // Miss-sweep floor: notes at or before this song-time are exempt + // from _updateMissed. Set to the connect-frame time when a MIDI + // device wires up mid-song — notes that passed while no events + // could arrive are unplayable, not misses. Lowered on seek-back + // (those notes become playable again) and cleared by _resetScoring. + let _missSweepFloor = -Infinity; + + /* ── HUD overlay (combo / accuracy / streak) ─────────────── */ + let _hudEl = null; + let _hudParentOrigPosition = null; // saved so _removeHud can restore it + + function _injectHud() { + if (_hudEl || !highwayCanvas) return; + const parent = highwayCanvas.parentElement; + if (!parent) return; + // Position the parent relative so absolute HUD anchors to + // the canvas. Read-only check first so we don't clobber an + // existing position the host page set. + const cur = parent.style.position || getComputedStyle(parent).position; + if (cur === 'static' || !cur) { + _hudParentOrigPosition = parent.style.position; + parent.style.position = 'relative'; + } + _hudEl = document.createElement('div'); + _hudEl.className = 'drum-h3d-hud'; + _hudEl.style.cssText = [ + 'position:absolute', 'top:10px', 'left:14px', + 'font-family:system-ui,sans-serif', 'font-size:13px', + 'color:#e2e8f0', 'pointer-events:none', 'z-index:6', + 'text-shadow:0 1px 2px rgba(0,0,0,0.8)', + 'min-width:140px', 'line-height:1.4', + ].join(';'); + parent.appendChild(_hudEl); + _refreshHud(); + } + + function _removeHud() { + if (_hudEl) { + const parent = _hudEl.parentNode; + if (parent) { + parent.removeChild(_hudEl); + // Restore position only if _injectHud changed it. + if (_hudParentOrigPosition !== null) { + parent.style.position = _hudParentOrigPosition; + _hudParentOrigPosition = null; + } + } + } + _hudEl = null; + } + + function _refreshHud() { + if (!_hudEl) return; + const total = _hits + _misses; + const pct = total ? Math.round((_hits / total) * 100) : 0; + const comboColor = _streak >= 30 ? '#fde047' : + _streak >= 10 ? '#86efac' : '#cbd5e1'; + _hudEl.innerHTML = + `
${_streak}× combo
` + + `
${_hits}/${total} (${pct}%)
` + + (_bestStreak ? `
best ${_bestStreak}
` : ''); + } + + /* ── Lane flash — pulse a lane's emissive briefly on hit ── */ + // Wallclock decay window — matches the 2D plugin's 300 ms flash. + const FLASH_MS = 300; + + function _applyLaneFlashes() { + // No longer used for visual feedback (chart notes now turn + // green/red on hit/miss via placeNote). Still drop expired + // entries from the buffer so the array doesn't grow forever + // if MIDI hits arrive while no chart is loaded. + const now = performance.now(); + while (_laneFlashes.length && now - _laneFlashes[0].wall > FLASH_MS) { + _laneFlashes.shift(); + } + } + + function _resetScoring() { + _hitKeys.clear(); + _missedKeys.clear(); + _hits = 0; _misses = 0; + _streak = 0; _bestStreak = 0; + _laneFlashes.length = 0; + _missSweepFloor = -Infinity; + } + + function _hitKey(t, lane) { + return t.toFixed(3) + '|' + lane; + } + + // MIDI note-on dispatcher. Resolves the played MIDI to a lane + // index, scans the visible chart window for an un-hit matching + // note within ±HIT_TOLERANCE_S, and updates scoring + visual + // feedback. Wrong-piece or missed-window hits flash red. + function _handleDrumHit(midiNote, velocity) { + _synthDrumHit(midiNote, velocity); + _synthEnsureCtx(); + + const piece = MIDI_TO_PIECE[midiNote]; + const lane = piece !== undefined ? PIECE_TO_LANE[piece] : undefined; + if (lane === undefined) { + _laneFlashes.push({ lane: -1, wall: performance.now(), kind: 'wrong' }); + return; + } + + if (!_latestNotes || _latestNotes.length === 0) { + _laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' }); + return; + } + + const t = _latestTime; + + let foundHit = false; + for (const n of _latestNotes) { + if (n.t > t + HIT_TOLERANCE_S) break; + if (n.t < t - HIT_TOLERANCE_S) continue; + if (n.lane !== lane) continue; + const key = _hitKey(n.t, n.lane); + if (_hitKeys.has(key)) continue; + _hitKeys.add(key); + foundHit = true; + break; + } + + if (foundHit) { + _hits++; + _streak++; + if (_streak > _bestStreak) _bestStreak = _streak; + _laneFlashes.push({ lane, wall: performance.now(), kind: 'hit' }); + } else { + _misses++; + _streak = 0; + _laneFlashes.push({ lane, wall: performance.now(), kind: 'wrong' }); + } + } + + // Walk recently-passed notes once per frame and mark any unhit + // ones as missed. Called from rebuildNotes after the bundle's + // currentTime is known. + function _updateMissed(t) { + if (!_latestNotes) return; + const cutoff = t - HIT_TOLERANCE_S - 0.02; + for (const n of _latestNotes) { + if (n.t > cutoff) break; + if (n.t < cutoff - 2) continue; // older than 2 s — already counted + if (n.t <= _missSweepFloor) continue; // passed before MIDI connected + const key = _hitKey(n.t, n.lane); + if (_hitKeys.has(key) || _missedKeys.has(key)) continue; + _missedKeys.add(key); + _misses++; + _streak = 0; + } + } + + function applySettings(detail) { + if (!detail) return; + if (detail.palette && PALETTES[detail.palette]) { + settings.palette = detail.palette; + activePalette = PALETTES[detail.palette]; + rebuildPaletteMaterials(); + rebuildKitMap(); + } + if (detail.pattern && DEMO_PATTERNS[detail.pattern]) { + settings.pattern = detail.pattern; + } + if (typeof detail.cameraAngle === 'number') { + settings.cameraAngle = Math.min(1, Math.max(0, detail.cameraAngle)); + positionCamera(); + } + } + + function rebuildPaletteMaterials() { + disposeMaterialArray(mDrumByLane); + disposeMaterialArray(mCymbalByLane); + disposeMaterialArray(mDrumHitByLane); + disposeMaterialArray(mDrumMissByLane); + disposeMaterialArray(mCymbalHitByLane); + disposeMaterialArray(mCymbalMissByLane); + mDrumByLane = new Array(LANES.length).fill(null); + mCymbalByLane = new Array(LANES.length).fill(null); + mDrumHitByLane = new Array(LANES.length).fill(null); + mDrumMissByLane = new Array(LANES.length).fill(null); + mCymbalHitByLane = new Array(LANES.length).fill(null); + mCymbalMissByLane = new Array(LANES.length).fill(null); + for (let i = 0; i < LANES.length; i++) { + const lane = LANES[i]; + const color = lane.kind === 'kick' ? KICK_COLOR : activePalette[lane.paletteIdx]; + if (lane.kind === 'drum') { + mDrumByLane[i] = new T.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.45, + roughness: 0.55, + metalness: 0.1, + }); + // Pre-cloned tint materials for hit (green) and miss (red), + // keyed per lane so proximity emissive adjustments on the base + // material don't affect tinted notes. + mDrumHitByLane[i] = mDrumByLane[i].clone(); + mDrumHitByLane[i].color.setHex(0x22c55e); + mDrumHitByLane[i].emissive.setHex(0x22c55e); + mDrumMissByLane[i] = mDrumByLane[i].clone(); + mDrumMissByLane[i].color.setHex(0xef4444); + mDrumMissByLane[i].emissive.setHex(0xef4444); + } else if (lane.kind === 'cymbal') { + mCymbalByLane[i] = new T.MeshStandardMaterial({ + color, + emissive: color, + emissiveIntensity: 0.55, + roughness: 0.25, + metalness: 0.7, + transparent: true, + opacity: 0.92, + }); + mCymbalHitByLane[i] = mCymbalByLane[i].clone(); + mCymbalHitByLane[i].color.setHex(0x22c55e); + mCymbalHitByLane[i].emissive.setHex(0x22c55e); + mCymbalMissByLane[i] = mCymbalByLane[i].clone(); + mCymbalMissByLane[i].color.setHex(0xef4444); + mCymbalMissByLane[i].emissive.setHex(0xef4444); + } + } + if (mKick) mKick.dispose(); + if (mKickHit) mKickHit.dispose(); + if (mKickMiss) mKickMiss.dispose(); + mKick = new T.MeshStandardMaterial({ + color: KICK_COLOR, + emissive: KICK_COLOR, + emissiveIntensity: 0.6, + roughness: 0.4, + metalness: 0.2, + }); + mKickHit = mKick.clone(); + mKickHit.color.setHex(0x22c55e); + mKickHit.emissive.setHex(0x22c55e); + mKickMiss = mKick.clone(); + mKickMiss.color.setHex(0xef4444); + mKickMiss.emissive.setHex(0xef4444); + } + + function disposeMaterialArray(arr) { + if (!arr) return; + for (const m of arr) if (m) m.dispose(); + } + + /* -- one-time scene setup --------------------------------------- */ + + function initScene() { + scene = new T.Scene(); + scene.background = new T.Color(FOG_COLOR); + scene.fog = new T.Fog(FOG_COLOR, FOG_START, FOG_END); + + cam = new T.PerspectiveCamera(60, 16 / 9, 0.1, 1000 * K); + positionCamera(); + + lights = new T.Group(); + const ambient = new T.AmbientLight(0xffffff, 0.4); + const dir = new T.DirectionalLight(0xffffff, 1.0); + dir.position.set(-50 * K, 200 * K, 200 * K); + lights.add(ambient); + lights.add(dir); + scene.add(lights); + + // Floor plane — wide darkened quad under the lanes. + // Use Math.max(1, LANE_COUNT) so a kick-only kit (LANE_COUNT=0) + // still produces a floor at least as wide as the kick bar (KICK_W). + const floorW = Math.max(1, LANE_COUNT) * LANE_GAP + 8 * K; + const floorD = (AHEAD + BEHIND + 0.5) * TS + 60 * K; + const gFloor = new T.PlaneGeometry(floorW, floorD); + const mFloor = new T.MeshStandardMaterial({ + color: 0x0a0e1a, + roughness: 0.95, + metalness: 0.0, + }); + const floor = new T.Mesh(gFloor, mFloor); + floor.rotation.x = -Math.PI / 2; + floor.position.set(0, -0.3 * K, -floorD / 2 + BEHIND * TS); + scene.add(floor); + + // Build lane stripes / dividers (static). + buildLanes(floorW, floorD); + + // Build the kit silhouette backboard. + kitMapGroup = new T.Group(); + scene.add(kitMapGroup); + rebuildKitMap(); + + // Hit-line bar in front of the camera, perpendicular to scroll. + const gHit = new T.BoxGeometry(floorW, 0.6 * K, 0.6 * K); + const mHit = new T.MeshStandardMaterial({ + color: 0xffffff, + emissive: 0xffffff, + emissiveIntensity: 0.9, + roughness: 0.3, + metalness: 0.6, + }); + const hitBar = new T.Mesh(gHit, mHit); + hitBar.position.set(0, 0.3 * K, 0); + scene.add(hitBar); + + // (Lane-flash boxes removed — hit/miss feedback now lives on + // the chart note itself via a per-note material clone in + // placeNote. Simpler visual: green note = you struck it, + // red note = it scrolled past unstruck.) + + // Notes group is rebuilt every frame — pooling could come later + // but at <100 visible notes per frame the GC cost is negligible. + notesGroup = new T.Group(); + scene.add(notesGroup); + + // Shared geometry. + gDrumDisc = new T.CylinderGeometry(DISC_R_BASE, DISC_R_BASE, DISC_H, 32); + gCymbalGem = new T.CylinderGeometry(CYMBAL_R * 0.15, CYMBAL_R, CYMBAL_H, 8, 1, false); + gKickBar = new T.BoxGeometry(KICK_W, KICK_H, KICK_D); + gAccentRing = new T.RingGeometry(DISC_R_BASE * 1.15, DISC_R_BASE * 1.4, 32); + gGhostRing = new T.RingGeometry(DISC_R_BASE * 0.55, DISC_R_BASE * 0.75, 24); + gSnareStripe = new T.BoxGeometry(DISC_R_BASE * 1.9, 0.25 * K, 0.4 * K); + gBellDot = new T.CircleGeometry(CYMBAL_R * 0.3, 16); + gFlamGrace = new T.CylinderGeometry( + DISC_R_BASE * FLAM_GRACE_SCALE, + DISC_R_BASE * FLAM_GRACE_SCALE, + DISC_H, + 24, + ); + + // Halo and ghost ring materials — additive emissive, palette-agnostic. + mAccentRing = new T.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.85, + side: T.DoubleSide, + }); + mGhostRing = new T.MeshBasicMaterial({ + color: 0xffffff, + transparent: true, + opacity: 0.55, + side: T.DoubleSide, + }); + mSnareStripe = new T.MeshStandardMaterial({ + color: 0xffffff, + emissive: 0xffffff, + emissiveIntensity: 0.5, + roughness: 0.3, + }); + mBellDot = new T.MeshBasicMaterial({ + color: 0xffeecc, + transparent: true, + opacity: 0.95, + side: T.DoubleSide, + }); + + rebuildPaletteMaterials(); + } + + function positionCamera() { + // cameraAngle: 0 = down the lanes (low + forward), 1 = top-down. + const a = settings.cameraAngle; + // Camera height ramps from low-ish to high; depth pulls back as we + // tilt down so the lanes still fit the frame. + const h = (45 + 180 * a) * K; + const d = (60 + 60 * (1 - a)) * K; + cam.position.set(0, h, d); + cam.lookAt(0, 0, -AHEAD * TS * 0.45); + } + + function buildLanes(_floorW, floorD) { + laneGroup = new T.Group(); + // Alternating lane stripes for the 7 hand lanes (same teal/blue + // as highway_3d, but a notch darker so the brighter discs pop). + const colors = [0x2d5476, 0x42759d]; + for (let i = 0; i < LANE_COUNT; i++) { + const x = LANE_X0 + i * LANE_GAP; + const g = new T.PlaneGeometry(LANE_GAP * 0.96, floorD); + const m = new T.MeshBasicMaterial({ + color: colors[i % 2], + transparent: true, + opacity: 0.32, + }); + const stripe = new T.Mesh(g, m); + stripe.rotation.x = -Math.PI / 2; + stripe.position.set(x, -0.25 * K, -floorD / 2 + BEHIND * TS); + laneGroup.add(stripe); + } + scene.add(laneGroup); + } + + function rebuildKitMap() { + // Clear existing children. + while (kitMapGroup.children.length) { + const c = kitMapGroup.children.pop(); + if (c.geometry) c.geometry.dispose(); + if (c.material) { + if (Array.isArray(c.material)) c.material.forEach(m => m.dispose()); + else c.material.dispose(); + } + } + + // Backboard plane far down the highway where guitar's fretboard + // would terminate. Each lane's piece silhouette sits over its + // lane's X position so the kit reads as a top-down map. + const farZ = -AHEAD * TS - 10 * K; + const boardW = LANE_COUNT * LANE_GAP + 6 * K; + const boardH = 28 * K; + const gBoard = new T.PlaneGeometry(boardW, boardH); + const mBoard = new T.MeshBasicMaterial({ + color: 0x0a1422, + transparent: true, + opacity: 0.85, + }); + const board = new T.Mesh(gBoard, mBoard); + board.position.set(0, boardH / 2 + 2 * K, farZ); + kitMapGroup.add(board); + + // Per-piece silhouettes — circle outlines (drums/cymbals) for + // each HAND lane. LANE_COUNT excludes the kick (which is + // rendered as a wide rectangle below instead). + for (let i = 0; i < LANE_COUNT; i++) { + const lane = LANES[i]; + if (!lane) continue; + const color = activePalette[lane.paletteIdx]; + const x = LANE_X0 + i * LANE_GAP; + const r = lane.kind === 'cymbal' ? 3.2 * K : 2.6 * K; + const segs = lane.kind === 'cymbal' ? 8 : 32; + const gOutline = new T.RingGeometry(r * 0.92, r, segs); + const mOutline = new T.MeshBasicMaterial({ + color, + transparent: true, + opacity: 0.85, + side: T.DoubleSide, + }); + const ring = new T.Mesh(gOutline, mOutline); + ring.position.set(x, boardH * 0.55 + 2 * K, farZ + 0.1 * K); + kitMapGroup.add(ring); + } + // Kick rectangle — only rendered when the active kit includes + // a kick lane (LANES.length > LANE_COUNT means a kick is appended + // at the end). Otherwise no bar appears, kit silhouette omits it. + const hasKick = LANES.length > LANE_COUNT; + if (hasKick) { + const kickG = new T.PlaneGeometry(boardW * 0.86, 2.0 * K); + const kickM = new T.MeshBasicMaterial({ + color: KICK_COLOR, + transparent: true, + opacity: 0.85, + }); + const kick = new T.Mesh(kickG, kickM); + kick.position.set(0, boardH * 0.18 + 2 * K, farZ + 0.1 * K); + kitMapGroup.add(kick); + } + } + + /* -- per-frame note rendering ----------------------------------- */ + + function buildNoteMesh(lane, variant) { + const laneCfg = LANES[lane]; + const group = new T.Group(); + + if (laneCfg.kind === 'kick') { + const bar = new T.Mesh(gKickBar, mKick); + if (variant === 'accent') bar.scale.setScalar(ACCENT_SCALE); + group.add(bar); + if (variant === 'accent') { + // Bright leading edge bar (a thin white strip on the + // camera-facing edge of the kick). Geometry + material + // are per-note — flag both as transient so disposeMeshTree + // releases them when the note recycles. + const edgeGeo = new T.BoxGeometry(KICK_W * 0.96, KICK_H * 1.05, 0.6 * K); + const edgeMat = new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9 }); + edgeGeo.userData.transient = true; + edgeMat.userData.transient = true; + const edge = new T.Mesh(edgeGeo, edgeMat); + edge.position.set(0, 0, KICK_D * 0.5); + group.add(edge); + } + return group; + } + + if (laneCfg.kind === 'drum') { + let scale = 1.0; + if (variant === 'ghost') scale = GHOST_SCALE; + else if (variant === 'accent') scale = ACCENT_SCALE; + + if (variant === 'ghost') { + // Hollow ring instead of disc — easier to read as "quiet". + // Material is per-note (palette-tinted but transparent) — + // flag it transient so disposeMeshTree releases it. + const ghostMat = new T.MeshBasicMaterial({ + color: activePalette[laneCfg.paletteIdx], + transparent: true, + opacity: 0.75, + side: T.DoubleSide, + }); + ghostMat.userData.transient = true; + ghostMat.userData.isGhostRing = true; + const ring = new T.Mesh(gGhostRing, ghostMat); + ring.rotation.x = -Math.PI / 2; + ring.scale.setScalar(scale); + group.add(ring); + } else { + const disc = new T.Mesh(gDrumDisc, mDrumByLane[lane]); + disc.scale.set(scale, 1, scale); + group.add(disc); + } + + if (laneCfg.subKind === 'snare' && variant !== 'ghost') { + const stripe = new T.Mesh(gSnareStripe, mSnareStripe); + stripe.position.set(0, DISC_H * 0.55, 0); + stripe.scale.set(scale, 1, scale); + group.add(stripe); + } + + if (variant === 'accent') { + const halo = new T.Mesh(gAccentRing, mAccentRing); + halo.rotation.x = -Math.PI / 2; + halo.position.y = DISC_H * 0.6; + halo.scale.setScalar(scale); + group.add(halo); + } + return group; + } + + if (laneCfg.kind === 'cymbal') { + // Cymbal "gem" — flattened bipyramid (truncated cone). The + // top-radius/bottom-radius ratio gives a faceted gem look. + let scale = 1.0; + if (variant === 'ghost') scale = GHOST_SCALE; + else if (variant === 'accent') scale = ACCENT_SCALE; + + const gem = new T.Mesh(gCymbalGem, mCymbalByLane[lane]); + gem.scale.setScalar(scale); + group.add(gem); + + if (laneCfg.subKind === 'hihat') { + // Open-hat hint: a thin ring around the gem (closed-hat + // is the bare gem). For the mockup we don't differentiate + // open/closed yet — TODO: drive from variant. + } + if (laneCfg.subKind === 'ride' && variant === 'bell') { + const dot = new T.Mesh(gBellDot, mBellDot); + dot.rotation.x = -Math.PI / 2; + dot.position.y = CYMBAL_H * 0.55; + group.add(dot); + } + if (variant === 'accent') { + const halo = new T.Mesh(gAccentRing, mAccentRing); + halo.rotation.x = -Math.PI / 2; + halo.position.y = CYMBAL_H * 0.55; + halo.scale.setScalar(scale * 1.05); + group.add(halo); + } + return group; + } + + return group; + } + + // Cache: converted drum_tab.hits → [{lane, t, variant}] sorted by t. + // Re-derive only when the bundle's drumTab object identity changes + // (one allocation per song-load, not per frame). + let _cachedDrumTabKey = null; + let _cachedRealNotes = null; + + function _realNotesFromDrumTab(drumTab) { + const hits = (drumTab && Array.isArray(drumTab.hits)) ? drumTab.hits : []; + const out = []; + for (const h of hits) { + const lane = PIECE_TO_LANE[h.p]; + if (lane === undefined) continue; // unknown piece — skip silently + out.push({ lane, t: +h.t || 0, variant: _variantForHit(h) }); + } + out.sort((a, b) => a.t - b.t); + return out; + } + + function rebuildNotes(bundle) { + // Clear the existing notes group — for <100 simultaneously visible + // notes the GC churn is fine and the code stays simple. + while (notesGroup.children.length) { + const c = notesGroup.children.pop(); + disposeMeshTree(c); + } + + // Real-data path: when the active sloppak ships a drum_tab, + // bundle.drumTab is populated by static/highway.js (slopsmith + // drums-from-scratch PR). Otherwise fall back to the hardcoded + // demo loop so the viz still has something to show on songs + // without drums or in standalone playback. + const drumTab = bundle && bundle.drumTab; + if (drumTab && Array.isArray(drumTab.hits)) { + if (_cachedDrumTabKey !== drumTab) { + _cachedDrumTabKey = drumTab; + _cachedRealNotes = _realNotesFromDrumTab(drumTab); + // New chart — reset scoring so combo/accuracy don't + // carry over from the previous song. Mirrors the v3 + // 2D plugin's bundle.isReady edge-detect behaviour. + _resetScoring(); + } + const t = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0; + // Snapshot for the MIDI handler — has to run BEFORE the + // visible-window walk so an in-flight pad hit fired + // between frames scores against the same view the user + // sees. _updateMissed also reads these to retire passed + // notes that the user didn't strike. + _latestNotes = _cachedRealNotes; + _latestTime = t; + // Only accumulate misses when a MIDI device is connected. + // With no device selected the user cannot hit notes, so + // counting passes as misses would corrupt the HUD accuracy. + // When a device was just connected, reset scoring first so + // notes that elapsed while no device was connected are not + // retroactively counted as misses. + // Gate on _midiHandle (the live wired session), NOT _midiInput: the + // latter is set as soon as a device is picked, but the async + // mi.open() may still be pending (slow / permission prompt), during + // which no events can arrive — counting passes then would bank false + // misses. _midiHandle is truthy only after a handle is opened+wired. + if (_midiHandle) { + if (_midiJustConnected) { + _midiJustConnected = false; + _resetScoring(); + // Floor the sweep at the connect frame: without this + // the _updateMissed call right below would bank the + // last ~2 s of notes as misses even though no input + // could have arrived for them yet. + _missSweepFloor = t; + } + // Seek-back past the floor makes those notes playable + // again — let them count. + if (t < _missSweepFloor) _missSweepFloor = t; + _updateMissed(t); + } + // Linear walk with early break — notes are sorted by time so + // the first note beyond AHEAD ends the visible window. + // Hit / missed notes get a tag passed through to placeNote + // so it can render them differently (hit → green tint, miss → + // red tint). The match is keyed on (t, lane), same as hit + // detection — multiple piece-ids on a lane (crash_l + + // crash_r) share the visual feedback. + for (const note of _cachedRealNotes) { + const dt = note.t - t; + if (dt > AHEAD) break; + if (dt < -BEHIND) continue; + const key = _hitKey(note.t, note.lane); + const status = _hitKeys.has(key) ? 'hit' : + _missedKeys.has(key) ? 'missed' : + 'pending'; + placeNote(note, dt, status); + } + return; + } + + // Demo-loop fallback: drumTab is absent or empty. If we just + // transitioned away from a real chart, clear stale scoring state + // so MIDI hits (and the HUD) don't score against the old notes. + if (_cachedDrumTabKey !== null) { + _cachedDrumTabKey = null; + _cachedRealNotes = []; + _latestNotes = []; + _latestTime = 0; + _resetScoring(); + } + const pat = DEMO_PATTERNS[settings.pattern]; + if (!pat) return; + const now = performance.now() / 1000 - t0; + const phase = now % pat.length; + for (let cycle = -1; cycle <= 1; cycle++) { + const cycleBase = cycle * pat.length; + for (const note of pat.notes) { + const dt = note.t + cycleBase - phase; + if (dt < -BEHIND || dt > AHEAD) continue; + placeNote(note, dt); + } + } + } + + function placeNote(note, dt, status) { + const laneCfg = LANES[note.lane]; + if (!laneCfg) return; + + const z = -dt * TS; // dt > 0 → upstream (negative Z) + const x = laneCfg.kind === 'kick' ? 0 : (LANE_X0 + note.lane * LANE_GAP); + const y = laneCfg.kind === 'kick' ? 0 : DISC_H * 0.5; + + const mesh = buildNoteMesh(note.lane, note.variant); + mesh.position.set(x, y, z); + + // Brighten emissive as the note approaches the hit line. + // 0 at AHEAD, peak at 0, then linger briefly past it. + const proximity = Math.max(0, 1 - Math.abs(dt) / 0.6); + if (laneCfg.kind === 'drum' && note.variant !== 'ghost' && mDrumByLane[note.lane]) { + // Subtle pulse via emissiveIntensity — palette-driven base + pulse. + mDrumByLane[note.lane].emissiveIntensity = 0.45 + proximity * 0.35; + } else if (laneCfg.kind === 'cymbal' && mCymbalByLane[note.lane]) { + mCymbalByLane[note.lane].emissiveIntensity = 0.55 + proximity * 0.35; + } else if (laneCfg.kind === 'kick' && mKick) { + mKick.emissiveIntensity = 0.6 + proximity * 0.5; + } + + // Slight scale-up as notes near the hit line gives the eye a + // "this is the moment" cue. Capped so it doesn't overshoot. + const approach = 1.0 + Math.max(0, 1 - Math.abs(dt) / 0.3) * 0.12; + mesh.scale.multiplyScalar(approach); + + // Status overrides — clone the shared lane material per-note so + // we can recolor JUST this note (green = hit, red = miss). The + // Swap in pre-cloned hit/miss tint materials — avoids per-frame + // per-note material.clone() calls while notesGroup is rebuilt. + if (status === 'hit' || status === 'missed') { + const isHit = status === 'hit'; + if (laneCfg.kind === 'kick') { + // Kick uses a single shared bar — swap to the pre-cloned + // tint material for consistent hit/miss feedback. + const tintKick = isHit ? mKickHit : mKickMiss; + mesh.traverse((child) => { + if (!child.isMesh || !child.material) return; + if (child.material === mKick && tintKick) child.material = tintKick; + }); + } else { + const tintDrum = isHit ? mDrumHitByLane[note.lane] : mDrumMissByLane[note.lane]; + const tintCymbal = isHit ? mCymbalHitByLane[note.lane] : mCymbalMissByLane[note.lane]; + const ghostColor = isHit ? 0x00ff88 : 0xff4444; + mesh.traverse((child) => { + if (!child.isMesh || !child.material) return; + const isBase = child.material === mDrumByLane[note.lane]; + const isCym = child.material === mCymbalByLane[note.lane]; + // Ghost ring uses a dedicated per-note MeshBasicMaterial + // with userData.isGhostRing — tint it in-place (already + // unique per note, no clone needed). Not the same as the + // kick accent edge which only sets userData.transient. + const isGhostRing = child.material.userData && child.material.userData.isGhostRing; + if (isBase && tintDrum) child.material = tintDrum; + else if (isCym && tintCymbal) child.material = tintCymbal; + else if (isGhostRing) child.material.color.setHex(ghostColor); + }); + } + } + + notesGroup.add(mesh); + + // Flam grace note — small auxiliary disc slightly before the main. + // Hit status applies to the whole flam (grace + main vanish + // together); missed status leaves the grace at normal size + // since shrinking just the main reads as enough miss-feedback. + if (note.variant === 'flam' && laneCfg.kind === 'drum') { + const graceDt = dt + FLAM_GRACE_OFFSET; + if (graceDt >= -BEHIND && graceDt <= AHEAD) { + const grace = new T.Mesh(gFlamGrace, mDrumByLane[note.lane]); + grace.position.set(x - DISC_R_BASE * 0.9, y, -graceDt * TS); + notesGroup.add(grace); + } + } + } + + function disposeMeshTree(node) { + // Shared geometries/materials (gDrumDisc, mDrumByLane[i], etc.) + // are owned by the renderer and disposed in teardown(). Per-note + // ephemeral geometries/materials are flagged with + // .userData.transient = true at construction time — dispose those. + node.traverse((child) => { + if (!child.isMesh) return; + if (child.geometry && child.geometry.userData && child.geometry.userData.transient) { + child.geometry.dispose(); + } + if (child.material && child.material.userData && child.material.userData.transient) { + child.material.dispose(); + } + }); + } + + /* -- size handling ---------------------------------------------- */ + + function applySize(w, h) { + if (!ren || !cam || !highwayCanvas) return; + const W = Math.max(1, Math.round(w || highwayCanvas.clientWidth || highwayCanvas.width || 1)); + const H = Math.max(1, Math.round(h || highwayCanvas.clientHeight || highwayCanvas.height || 1)); + ren.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + ren.setSize(W, H, false); + cam.aspect = W / H; + cam.updateProjectionMatrix(); + } + + /* -- teardown --------------------------------------------------- */ + + // Drop scene geometry / materials / renderer without unsubscribing + // lifecycle handlers (settings, kit, MIDI). Used for kit-change + // rebuild where the renderer comes right back up against the same + // canvas. Returns true when the renderer was re-created cleanly, + // false when WebGL re-init failed — callers must NOT proceed to + // initScene() on false (the scene would draw into nothing). + function _disposeScene() { + if (notesGroup) { + while (notesGroup.children.length) { + disposeMeshTree(notesGroup.children.pop()); + } + } + if (kitMapGroup) { + while (kitMapGroup.children.length) { + const c = kitMapGroup.children.pop(); + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + } + } + // Dispose laneGroup stripe meshes (PlaneGeometry + MeshBasicMaterial per + // lane) so kit-change rebuilds don't leak GPU resources. + if (laneGroup) { + while (laneGroup.children.length) { + const c = laneGroup.children.pop(); + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + } + } + disposeMaterialArray(mDrumByLane); + disposeMaterialArray(mCymbalByLane); + disposeMaterialArray(mDrumHitByLane); + disposeMaterialArray(mDrumMissByLane); + disposeMaterialArray(mCymbalHitByLane); + disposeMaterialArray(mCymbalMissByLane); + if (mKick) mKick.dispose(); + if (mKickHit) mKickHit.dispose(); + if (mKickMiss) mKickMiss.dispose(); + if (mAccentRing) mAccentRing.dispose(); + if (mGhostRing) mGhostRing.dispose(); + if (mSnareStripe) mSnareStripe.dispose(); + if (mBellDot) mBellDot.dispose(); + if (gDrumDisc) gDrumDisc.dispose(); + if (gCymbalGem) gCymbalGem.dispose(); + if (gKickBar) gKickBar.dispose(); + if (gAccentRing) gAccentRing.dispose(); + if (gGhostRing) gGhostRing.dispose(); + if (gSnareStripe) gSnareStripe.dispose(); + if (gBellDot) gBellDot.dispose(); + if (gFlamGrace) gFlamGrace.dispose(); + if (ren) ren.dispose(); + scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null; + mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null; + mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null; + mAccentRing = mGhostRing = mSnareStripe = mBellDot = null; + gDrumDisc = gCymbalGem = gKickBar = null; + gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null; + // Re-create the renderer against the existing canvas. initScene + // populates everything we just disposed. + try { + ren = new T.WebGLRenderer({ + canvas: highwayCanvas, + antialias: true, + alpha: false, + }); + ren.setClearColor(FOG_COLOR, 1); + return true; + } catch (e) { + console.error('[Drum-Hwy3D] WebGL2 re-init failed:', e); + ren = null; + return false; + } + } + + function teardown() { + if (_settingsHandler) { + window.removeEventListener('drum_h3d:settings', _settingsHandler); + _settingsHandler = null; + } + if (_kitHandler) { + window.removeEventListener('drum_h3d:kit', _kitHandler); + _kitHandler = null; + } + if (notesGroup) { + while (notesGroup.children.length) { + disposeMeshTree(notesGroup.children.pop()); + } + } + if (kitMapGroup) { + while (kitMapGroup.children.length) { + const c = kitMapGroup.children.pop(); + if (c.geometry) c.geometry.dispose(); + if (c.material) c.material.dispose(); + } + } + disposeMaterialArray(mDrumByLane); + disposeMaterialArray(mCymbalByLane); + disposeMaterialArray(mDrumHitByLane); + disposeMaterialArray(mDrumMissByLane); + disposeMaterialArray(mCymbalHitByLane); + disposeMaterialArray(mCymbalMissByLane); + if (mKick) mKick.dispose(); + if (mKickHit) mKickHit.dispose(); + if (mKickMiss) mKickMiss.dispose(); + if (mAccentRing) mAccentRing.dispose(); + if (mGhostRing) mGhostRing.dispose(); + if (mSnareStripe) mSnareStripe.dispose(); + if (mBellDot) mBellDot.dispose(); + if (gDrumDisc) gDrumDisc.dispose(); + if (gCymbalGem) gCymbalGem.dispose(); + if (gKickBar) gKickBar.dispose(); + if (gAccentRing) gAccentRing.dispose(); + if (gGhostRing) gGhostRing.dispose(); + if (gSnareStripe) gSnareStripe.dispose(); + if (gBellDot) gBellDot.dispose(); + if (gFlamGrace) gFlamGrace.dispose(); + if (ren) ren.dispose(); + scene = cam = ren = lights = laneGroup = kitMapGroup = notesGroup = null; + mDrumByLane = mCymbalByLane = mKick = mKickHit = mKickMiss = null; + mDrumHitByLane = mDrumMissByLane = mCymbalHitByLane = mCymbalMissByLane = null; + mAccentRing = mGhostRing = mSnareStripe = mBellDot = null; + gDrumDisc = gCymbalGem = gKickBar = null; + gAccentRing = gGhostRing = gSnareStripe = gBellDot = gFlamGrace = null; + _isReady = false; + } + + /* -- setRenderer contract --------------------------------------- */ + + const instance = { + contextType: 'webgl2', + + init(canvas, _bundle) { + if (_isReady) teardown(); + highwayCanvas = canvas; + settings = readSettings(); + activePalette = PALETTES[settings.palette]; + + loadThree().then(() => { + if (!highwayCanvas) return; // destroyed before load resolved + try { + ren = new T.WebGLRenderer({ + canvas: highwayCanvas, + antialias: true, + alpha: false, + }); + ren.setClearColor(FOG_COLOR, 1); + } catch (e) { + console.error('[Drum-Hwy] WebGL2 init failed:', e); + return; + } + initScene(); + applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight); + + _settingsHandler = (ev) => applySettings(ev && ev.detail); + window.addEventListener('drum_h3d:settings', _settingsHandler); + + // Kit-change: full scene rebuild because lane count, + // positions, kit silhouette and all materials depend + // on the active kit. Cheaper than dirty-tracking each + // affected piece. + _kitHandler = () => { + if (!_isReady || !ren) return; + _cachedDrumTabKey = null; // force note re-resolution + _cachedRealNotes = null; + // Drop the scene + rebuild against the new LANES. + // initScene reads the module-level LANES which was + // recomputed by drumH3dSetKit before this event. + const w = highwayCanvas ? highwayCanvas.clientWidth : 0; + const h = highwayCanvas ? highwayCanvas.clientHeight : 0; + if (!_disposeScene()) { + // WebGL renderer re-init failed — don't build a + // scene that would draw into nothing. Mark not + // ready and fully tear down so the plugin fails + // loudly rather than freezing half-initialised. + _isReady = false; + teardown(); + return; + } + initScene(); + applySize(w, h); + }; + window.addEventListener('drum_h3d:kit', _kitHandler); + + // MIDI lifecycle. _midiInit is idempotent across init + // calls (re-running setRenderer for the same plugin + // doesn't re-request browser permission); _midiResume + // wires the message handler whenever this instance + // claims focus, which for the non-splitscreen case is + // immediately at init. + _instances.add(instance); + _activeInstance = instance; + _midiInit(); + _synthInit(); + _midiResume(); + + _injectHud(); + + _isReady = true; + }); + }, + + draw(bundle) { + if (!_isReady || !ren || !scene || !cam) return; + rebuildNotes(bundle); + _applyLaneFlashes(); + _refreshHud(); + ren.render(scene, cam); + }, + + resize(w, h) { + if (!_isReady) return; + applySize(w, h); + }, + + destroy() { + _instances.delete(instance); + // If the focused instance is going away but others remain + // (splitscreen teardown of one panel), promote a survivor so + // _midiOnMessage keeps routing instead of dropping every + // event on its `if (!_activeInstance) return` guard — same + // behavior as keys_highway_3d. + if (_activeInstance === instance) { + _activeInstance = null; + for (const inst of _instances) { _activeInstance = inst; break; } + } + if (_instances.size === 0) _midiReleaseSession(); + _removeHud(); + teardown(); + highwayCanvas = null; + }, + // Exposed for module-level MIDI router. The receiver runs on + // every note-on dispatched to _activeInstance. + _handleDrumHit, + }; + return instance; + } + + /* ====================================================================== + * Register + * ====================================================================== */ + + window.slopsmithViz_drum_highway_3d = createFactory; + // slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_`. + window.feedBackViz_drum_highway_3d = window.slopsmithViz_drum_highway_3d; + // Static contextType so core can read it for canvas-swap decisions + // before constructing a throwaway renderer instance. + window.slopsmithViz_drum_highway_3d.contextType = 'webgl2'; + // Auto-select on sloppaks that carry a drum_tab.json (sloppak-spec + // §5.3). Sorts before `drums` alphabetically so Auto mode picks the + // 3D viz over the 2D fallback. The 2D drums plugin keeps its own + // matchesArrangement as a defence-in-depth fallback for WebGL2-less + // browsers — _autoMatchViz gates webgl2 contextType on _canRun3D + // before installing the renderer, so non-WebGL2 environments will + // skip past this and pick the 2D one. + // + // Steal-guard (bundling): `has_drum_tab` is a PACK-level flag, and Auto + // mode is first-match-wins in plugin order — `drum_highway_3d` sorts + // before `highway_3d`, so matching on the flag alone would take every + // full-band pack away from the guitar highway even when the active + // arrangement is Lead/Bass. Only auto-claim when the ACTIVE arrangement + // is a drum part, or when nothing more specific can render the song + // (no keys notation, no guitar-family arrangement). A drummer on a + // full-band pack picks this viz from the picker as before; instrument- + // selector routing (server.py highway_ws) will auto-route once a drums + // selector entry lands. + window.slopsmithViz_drum_highway_3d.matchesArrangement = function (songInfo) { + if (!songInfo || !songInfo.has_drum_tab) return false; + const arr = songInfo.arrangement || ''; + if (/\b(?:drums?|percussion)\b/i.test(arr)) return true; + if (songInfo.has_notation) return false; + return !/\b(?:lead|rhythm|bass|combo|guitar)\b/i.test(arr); + }; +})(); diff --git a/plugins/drum_highway_3d/screenshots/drum-highway-3d.png b/plugins/drum_highway_3d/screenshots/drum-highway-3d.png new file mode 100644 index 0000000..b53864e Binary files /dev/null and b/plugins/drum_highway_3d/screenshots/drum-highway-3d.png differ diff --git a/plugins/drum_highway_3d/settings.html b/plugins/drum_highway_3d/settings.html new file mode 100644 index 0000000..12be1a7 --- /dev/null +++ b/plugins/drum_highway_3d/settings.html @@ -0,0 +1,432 @@ +
+

3D Drum Highway

+

+ Pick a song with a drum tab (sloppak-spec §5.3), choose 3D + Drum Highway from the viz picker. The plugin auto-attaches + to your MIDI controller and routes chart pieces through the kit + you configure below. +

+ + +
+ + + +
+ + +
+ + +

0 = down the lanes · 1 = top-down

+
+ + +
+

MIDI input

+ + +

+ Auto-picks the first non-passthrough input on load. Pick "none" to pause hit and miss tracking. +

+ + + +
+ + +
+

My kit

+

+ Add the pieces you actually have, in the order you want them + on the highway (left → right). The default fallback routing + covers most common kit setups; use the fallbacks panel below + to reroute any chart piece to a different lane on your kit, + or set it to "—" to silently drop it. +

+ + + + + +
+ +
+ + +
+ +
+ Chart fallbacks for pieces I don't have +

+ When a song uses a piece that isn't on your kit, route it + to one of your lanes. Set "—" to silently drop that piece. +

+
+
+ +
+ + + + +
+ + +
+ + +
diff --git a/plugins/keys_highway_3d/LICENSE b/plugins/keys_highway_3d/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/plugins/keys_highway_3d/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/plugins/keys_highway_3d/README.md b/plugins/keys_highway_3d/README.md new file mode 100644 index 0000000..8e96613 --- /dev/null +++ b/plugins/keys_highway_3d/README.md @@ -0,0 +1,24 @@ +# Keys Highway 3D + +RS+-style falling-note 3D piano highway for [Slopsmith](https://github.com/got-feedback/feedback), fed by the **Sloppak Notation Format** (sloppak-spec §5.3) — part of the piano/keys first-class epic (slopsmith#828, plugin workstream slopsmith#824). + +- Consumes the `notation_info` / `notation_measures` highway-WS stream over a private per-instance socket and flattens measure → staff → voice → beat → note into `{midi, t, durSec, hand}` (durations derived from written `dur`/`dot`/`tu` at the running tempo; ties extend; overlap-clamped). +- 3D perspective highway to a vanishing point with a real white/black-key keyboard; per-key **pitch-class colours** (Synthesia convention — C red, D yellow, E blue, …) with hand (rh/lh) as a secondary brightness cue. +- Full RS+ visual treatment: key **letter glyphs** printed on the active-range key tops (cached CanvasTextures), **bevelled gem-style note blocks** (ExtrudeGeometry, geometry/material caches keyed by size and pitch-class×hand), **floating bar numbers** scrolling with the notes, **active-range lane dimming** so the playable span pops, and a **glowing pulsing hit-line** (layered additive gradient planes — no postprocessing). +- Performance discipline: no per-frame allocations or DOM queries in `draw()`. Chart-scoped resources — note geometries/materials, bar-number and glow textures — are cached and disposed on chart teardown; the key-letter glyph `CanvasTexture`s live in a shared module-level cache that survives teardown and is reused across instances. +- Auto-selected for arrangements with notation via `matchesArrangement(songInfo.has_notation)`; capability-native `visualization` provider declaration. +- **Web MIDI input scoring**: module-level MIDI singleton (one access per tab, focused-instance routing) with device auto-connect by saved id+name, loopback blocklist, channel filter, transpose and CC64 sustain (`keys3d_` localStorage prefix; `window.keysH3d*` settings API). Hit detection matches played MIDI against the flattened chart notes within ±0.10 s with per-note dedupe and a missed-note sweep (only while a device is connected — never retroactive across a mid-song connect). +- **Live hit feedback on the MIDI path** (not the chart): key depress (~4° back-edge pivot, ~120 ms spring; the key letter rides along), wrong-note red key flash, and a vertical flame flare on hits (pooled additive sprites, white-hot base fading into the pitch-class colour, ~400 ms). +- **End-of-run stats**: POSTs `/api/stats` `{filename, arrangement, score, accuracy}` exactly once per run with the same formula as the guitar notedetect path (`accuracy = hits / max(1, hits+misses)`, `score = round(hits·100·accuracy)`), then notifies the progression core when present. +- **Capability wiring** (all guarded for servers without the hosts): registers as a note-detection `midi` provider (`keys-midi`, `verify.target`), opens a per-song binding scoped to the chart's keys range, reports hit/miss observability events, and exposes Web MIDI inputs to the audio-input domain with pseudonymized labels (`midi-input-1`, …) via `source.enumerate/describe/open/close`. +- Headless test hook: `window.__keysHwTest = { injectNoteOn(midi, when), getScore() }`. + +## Tests + +``` +node --test tests/*.test.js +``` + +## License + +AGPL-3.0. diff --git a/plugins/keys_highway_3d/assets/thumb.svg b/plugins/keys_highway_3d/assets/thumb.svg new file mode 100644 index 0000000..86660b8 --- /dev/null +++ b/plugins/keys_highway_3d/assets/thumb.svg @@ -0,0 +1,5 @@ + + + + KH + diff --git a/plugins/keys_highway_3d/plugin.json b/plugins/keys_highway_3d/plugin.json new file mode 100644 index 0000000..a47797f --- /dev/null +++ b/plugins/keys_highway_3d/plugin.json @@ -0,0 +1,64 @@ +{ + "id": "keys_highway_3d", + "name": "Keys Highway 3D", + "version": "0.1.0", + "description": "RS+-style 3D falling-note piano highway fed by the Sloppak Notation Format, with Web MIDI input scoring.", + "type": "visualization", + "bundled": true, + "script": "screen.js", + "standards": [ + "capability-pipelines.v1", + "plugin-runtime-idempotent.v1" + ], + "capabilities": { + "visualization": { + "roles": [ + "provider" + ], + "operations": [ + "renderer.create", + "renderer.destroy" + ], + "emits": [ + "renderer-ready", + "renderer-failed" + ], + "mode": "active", + "safety": "safe" + }, + "note-detection": { + "roles": [ + "provider" + ], + "operations": [ + "verify.target" + ], + "emits": [ + "hit", + "miss" + ], + "mode": "active", + "safety": "sensitive" + }, + "midi-input": { + "roles": [ + "requester" + ], + "requests": [ + "discover", + "list-sources", + "select-source", + "open-source", + "close-source" + ], + "mode": "active", + "compatibility": "degrade-noop", + "ownership": "requester-only", + "safety": "sensitive", + "description": "Reads the MIDI keyboard input through the core midi-input domain (Web-MIDI provider ships built-in with the domain). Absent domain \u2192 no MIDI devices, fail-soft.", + "version": 1 + } + }, + "category": "practice", + "icon": "assets/thumb.svg" +} diff --git a/plugins/keys_highway_3d/screen.js b/plugins/keys_highway_3d/screen.js new file mode 100644 index 0000000..69f8bca --- /dev/null +++ b/plugins/keys_highway_3d/screen.js @@ -0,0 +1,2240 @@ +// Keys Highway 3D — RS+-style falling-note piano highway. +// +// Notation-format-fed (sloppak-spec §5.3): consumes the `notation_info` + +// `notation_measures` highway-WS stream over a private per-instance socket +// (same pattern as Staff View) and flattens measure → staff → voice → beat → +// note into a falling-note list `{midi, t, durSec, hand}`. No guitar-wire +// `midi = s*24+f` indirection — that legacy path stays with the 2D piano +// plugin. +// +// Visual contract is the frame analysis on slopsmith#824 (RS+ reference): +// 3D perspective highway to a vanishing point, notes landing on a real 3D +// keyboard, per-key Synthesia-style PITCH-CLASS colours (hand is only a +// secondary brightness cue), active-range key highlighting with letters, +// a glowing hit-line, bevelled cuboid notes sized by durSec, floating bar +// numbers, and key-depress + flame feedback driven by the LIVE MIDI input +// path (not the chart). +// +// Reuses verbatim from highway_3d / drum_highway_3d: Three.js loader, world +// scale (K), fog and light rig — so the three highways read as one family. + +(function () { + 'use strict'; + + /* ====================================================================== + * Verbatim from highway_3d — keep in sync if upstream tweaks them + * ====================================================================== */ + + const THREE_URL = '/static/vendor/three/three.module.min.js'; + const THREE_CDN = 'https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js'; + + const SCALE = 2.25; + const K = SCALE / 300; + + const FOG_COLOR = 0x1a1a2e; + const FOG_START = 850 * K; // fog pushed past the runway end — far notes stay + const FOG_END = 1600 * K; // visible, only the extreme distance fades + + let T = null; + let threeLoadPromise = null; + function loadThree() { + if (!threeLoadPromise) { + threeLoadPromise = import(THREE_URL) + .then(mod => { T = mod; return mod; }) + .catch(() => import(THREE_CDN) + .then(mod => { T = mod; return mod; }) + .catch(e => { + console.error('[Keys-Hwy3D] Three.js load failed:', e); + threeLoadPromise = null; + throw e; + })); + } + return threeLoadPromise; + } + + /* ====================================================================== + * Keys-specific constants + * ====================================================================== */ + + // World scroll speed (units / second) — matches the sibling highways. + const TS = 130 * K; + + // Per-pitch-class colours (Synthesia convention observed in the RS+ + // reference frames: C=red, D=yellow, E=blue, F=light blue-grey, …). + // Index = midi % 12 (C, C#, D, …, B). Sharps take a dimmed blend of + // their neighbours so black-key notes stay distinguishable. + const PITCH_CLASS_COLORS = [ + 0xff3030, // C — red + 0xb04818, // C# + 0xffd400, // D — yellow + 0x9aa018, // D# + 0x2870ff, // E — blue + 0x8fb8c8, // F — light blue-grey + 0x4a7a68, // F# + 0x30c050, // G — green + 0x807838, // G# + 0xff8020, // A — orange + 0xa05870, // A# + 0xc050ff, // B — violet + ]; + + // Hand cue is SECONDARY (slopsmith#824 design call): right hand renders + // at full brightness, left hand slightly darkened — colour stays the + // pitch class. + const HAND_BRIGHTNESS = { rh: 1.0, lh: 0.72 }; + + // Note block cross-section height and bevel (world units). The bevel + // turns the flat slabs into glossy gem-like blocks that catch the light + // on their edges — the RS+ reference look. + const NOTE_H = 4 * K; + const NOTE_BEVEL = 0.55 * K; + const NOTE_EMISSIVE_BASE = 0.08; // resting note glow + const CONSUME_GLOW = 5.0; // peak glow as a note is eaten at the hit-line + const LABEL_FADE_DIST = 80 * K; // note-name fades out over this distance past the hit-line (~0.6s) + // Gem vertical gradient (bottom shade → top highlight), baked per-vertex into + // the note geometry so a block reads as a lit 3D gem instead of a flat fill — + // same approach as the bundled guitar highway_3d (`gNoteGrad`). The ramp is + // greyscale so one geometry serves every pitch-class colour; the material + // multiplies its colour by it via vertexColors. + const GEM_SHADE_BOT = 0.12, GEM_SHADE_TOP = 1.1; // strong gem gradient (top slightly blows toward a highlight) + + const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; + + // Key-range padding. + const RANGE_PAD = 2; // semitones of plain keys beyond the active range + + const NOTATION_CHUNK_TIMEOUT_MS = 20000; + + /* ====================================================================== + * MIDI scoring constants (mirrors the 2D piano plugin) + * ====================================================================== */ + + // ±100 ms hit window — same as the 2D piano plugin's HIT_TOLERANCE so + // users get identical timing across both keys visualisations. + const HIT_TOLERANCE_S = 0.10; + + // Persisted settings, `keys3d_` prefix (piano plugin STORE_KEYS pattern). + const STORE_KEYS = { + midiPick: 'keys3d_midi_pick', // {id, name} JSON (drum-h3d v2 pattern) + midiChannel: 'keys3d_midi_ch', // -1 = all channels + transpose: 'keys3d_transpose', // semitones added to incoming notes + }; + + // Inputs whose name matches this are skipped by auto-connect (loopbacks + // and passthroughs, same blocklist as drum_highway_3d). + const _MIDI_BLOCKLIST_RE = /midi through|^thru\b|^iac\b/i; + + // Key-depress feedback: ~4° tilt around the key's back edge, settled in + // ~120 ms (exponential spring, tau ≈ 30 ms → 98% in 4τ). + const KEY_PRESS_ANGLE = Math.PI / 45; + const KEY_PRESS_TAU_MS = 30; + + // Wrong-note red key flash duration. + const WRONG_FLASH_MS = 250; + + // Flame flare lifetime + pool size (pooled additive sprites — no + // per-hit allocations). + const FLAME_MS = 400; + const FLAME_POOL_SIZE = 24; + + // Capability identity for the note-detection / audio-input domains. + const ND_PROVIDER_ID = 'keys-midi'; + const PLUGIN_ID = 'keys_highway_3d'; + + /* ====================================================================== + * Notation data layer (pure — exported via createFactory.__test) + * ====================================================================== */ + + // Derive a beat's sounding duration in seconds from its written duration + // and the running tempo: base = (60/tempo) * (4/dur), dots multiply by + // 2 − 2^(−dot), tuplets by tu[1]/tu[0]. + function beatDurSec(beat, tempo) { + const dur = Number(beat && beat.dur); + if (!Number.isFinite(dur) || dur <= 0 || !Number.isFinite(tempo) || tempo <= 0) return null; + const base = (60 / tempo) * (4 / dur); + const dot = Number(beat.dot) || 0; + const dotFactor = 2 - Math.pow(2, -dot); + const tu = Array.isArray(beat.tu) && beat.tu.length === 2 + && Number(beat.tu[0]) > 0 && Number(beat.tu[1]) > 0 + ? Number(beat.tu[1]) / Number(beat.tu[0]) : 1; + return base * dotFactor * tu; + } + + // Flatten accumulated notation (info + measures) into a render-ready, + // time-sorted note list: [{midi, t, durSec, hand, measureIdx}]. + // + // - `hand` = staff id (rh/lh per sloppak-spec; any other staff id is + // passed through and rendered with rh brightness). + // - durSec: derived from dur/dot/tu at the running tempo (tempo state + // carries across measures; measures may omit `tempo` when unchanged). + // Tied notes (`tied: true`) extend the previous sounding note of the + // same midi in the same staff+voice instead of emitting a new block. + // Fallback when no tempo has been seen: gap to the next onset in the + // same voice (or +2s for the final beat). + // Sanity clamp: never longer than the gap to the next same-staff + // same-midi onset (prevents overlap artifacts from tempo-map drift); + // floor at 0.05s so 32nds stay visible. + // - Grace beats (typed `grace` field) render as ordinary short notes at + // their written duration — good enough for a falling-note highway. + function flattenNotation(measures) { + const out = []; + let tempo = null; + // staff|voice → array of beats (for next-onset fallback + ties) + const lastNoteByKey = new Map(); // `${staff}|${voice}|${midi}` → out[] index + + // First pass: collect every beat with its staff/voice context in + // measure order so next-onset lookups can be done per voice. + const beatsByVoice = new Map(); // `${staff}|${voice}` → [{beat, measure}] + for (const measure of Array.isArray(measures) ? measures : []) { + if (!measure || typeof measure !== 'object') continue; + const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {}; + for (const staffId of Object.keys(staves)) { + const staff = staves[staffId]; + const voices = staff && Array.isArray(staff.voices) ? staff.voices : []; + for (const voice of voices) { + if (!voice || !Array.isArray(voice.beats)) continue; + const vid = `${staffId}|${voice.v != null ? voice.v : 1}`; + if (!beatsByVoice.has(vid)) beatsByVoice.set(vid, []); + const list = beatsByVoice.get(vid); + for (const beat of voice.beats) { + if (!beat || typeof beat !== 'object') continue; + list.push({ beat, measure, staffId, vid }); + } + } + } + } + for (const list of beatsByVoice.values()) { + list.sort((a, b) => (Number(a.beat.t) || 0) - (Number(b.beat.t) || 0)); + } + + // Second pass in global measure order so the tempo state machine sees + // tempo changes when they happen. + for (const measure of Array.isArray(measures) ? measures : []) { + if (!measure || typeof measure !== 'object') continue; + const mTempo = Number(measure.tempo); + if (Number.isFinite(mTempo) && mTempo > 0) tempo = mTempo; + const measureIdx = Number(measure.idx) || 0; + const staves = measure.staves && typeof measure.staves === 'object' ? measure.staves : {}; + for (const staffId of Object.keys(staves)) { + const staff = staves[staffId]; + const voices = staff && Array.isArray(staff.voices) ? staff.voices : []; + for (const voice of voices) { + if (!voice || !Array.isArray(voice.beats)) continue; + const vid = `${staffId}|${voice.v != null ? voice.v : 1}`; + const voiceBeats = beatsByVoice.get(vid) || []; + for (const beat of voice.beats) { + if (!beat || typeof beat !== 'object' || beat.rest) continue; + const t = Number(beat.t); + if (!Number.isFinite(t)) continue; + const notes = Array.isArray(beat.notes) ? beat.notes : []; + let durSec = beatDurSec(beat, tempo); + if (durSec == null) { + // No tempo seen — fall back to next onset in voice. + const i = voiceBeats.findIndex(e => e.beat === beat); + const next = i >= 0 ? voiceBeats.slice(i + 1).find(e => !e.beat.rest) : null; + durSec = next ? Math.max(0.05, Number(next.beat.t) - t) : 2.0; + } + for (const note of notes) { + if (!note || typeof note !== 'object') continue; + const midi = Number(note.midi); + if (!Number.isFinite(midi) || midi < 0 || midi > 127) continue; + const key = `${vid}|${midi}`; + if (note.tied && lastNoteByKey.has(key)) { + // Extend the previous sounding note; no new block. + const prev = out[lastNoteByKey.get(key)]; + prev.durSec = Math.max(prev.durSec, (t - prev.t) + durSec); + continue; + } + out.push({ + midi, + t, + durSec: Math.max(0.05, durSec), + hand: staffId, + measureIdx, + }); + lastNoteByKey.set(key, out.length - 1); + } + } + } + } + } + + out.sort((a, b) => a.t - b.t || a.midi - b.midi); + + // Overlap clamp: a note never rings past the next onset of the same + // midi on the same hand. + const lastByHandMidi = new Map(); + for (let i = 0; i < out.length; i++) { + const note = out[i]; + const key = `${note.hand}|${note.midi}`; + if (lastByHandMidi.has(key)) { + const prev = out[lastByHandMidi.get(key)]; + if (prev.t + prev.durSec > note.t) { + prev.durSec = Math.max(0.05, note.t - prev.t); + } + } + lastByHandMidi.set(key, i); + } + return out; + } + + // Active key range with padding, clamped to the 88-key piano (A0–C8). + // `activeLow`/`activeHigh` are the actual chart extremes (the keys that + // tint, print their letter, and keep bright lanes); `low`/`high` add + // RANGE_PAD semitones of plain keys around them. Keeping the active span + // explicit (instead of re-deriving it as low+RANGE_PAD) stays correct at + // the 88-key clamp edges. + function keyRange(notes) { + if (!notes.length) { + // Empty chart: render a neutral two-octave keyboard with an + // empty active span (activeLow > activeHigh — nothing lights). + return { low: 48, high: 72, activeLow: 60, activeHigh: 59 }; + } + let low = 127, high = 0; + for (const n of notes) { + if (n.midi < low) low = n.midi; + if (n.midi > high) high = n.midi; + } + return { + low: Math.max(21, low - RANGE_PAD), + high: Math.min(108, high + RANGE_PAD), + activeLow: low, + activeHigh: high, + }; + } + + // Measure markers [{idx, t}] for floating bar numbers. + function measureMarkers(measures) { + const out = []; + for (const m of Array.isArray(measures) ? measures : []) { + if (!m || typeof m !== 'object') continue; + const t = Number(m.t); + const idx = Number(m.idx); + if (Number.isFinite(t) && Number.isFinite(idx)) out.push({ idx, t }); + } + return out; + } + + // Letter printed on a key top (C, C#, D, …) — pure. + function noteLetter(midi) { + return NOTE_NAMES[((midi % 12) + 12) % 12]; + } + + // World-space Z of a chart event at song-time `now`: the event sits at + // `hitZ` exactly when now === eventT, approaching from -Z at `speed` + // world-units per second. Shared by the note blocks (front edge) and the + // floating bar-number sprites so they scroll in lockstep. + function scrollZ(eventT, now, hitZ, speed) { + return hitZ - (eventT - now) * speed; + } + + /* ====================================================================== + * Pure scoring logic (exported via createFactory.__test) + * ====================================================================== */ + + // Dedupe key for a chart note — same convention as the piano plugin's + // _noteKey (time quantised to ms so float drift can't double-count). + function noteKey(t, midi) { + return t.toFixed(3) + '|' + midi; + } + + // Score/accuracy formula MUST mirror the guitar notedetect path + // (static/v3/stats-recorder.js, which itself mirrors lib/song_score.py): + // accuracy = hits / max(1, hits + misses) (0..1 fraction) + // score = round(hits * 100 * accuracy) (monotonic in both) + function accuracyOf(hits, misses) { + return hits / Math.max(1, hits + misses); + } + function scoreOf(hits, misses) { + return Math.round(hits * 100 * accuracyOf(hits, misses)); + } + + // Judge a played MIDI note against the time-sorted flattened note list + // (piano plugin _checkHit port, minus the guitar-wire chord path — keys + // chords are already flattened to simultaneous notes). Returns the + // dedupe key of the matched un-hit chart note, or null when the play is + // a wrong note / outside every window / a duplicate. The caller owns + // adding the key to hitKeys and all hit/miss bookkeeping. + function judgeHit(notes, playedMidi, t, hitKeys, tol) { + if (!Array.isArray(notes) || !notes.length) return null; + for (const n of notes) { + if (n.t > t + tol + 0.5) break; + if (n.t < t - tol - 0.5) continue; + if (n.midi !== playedMidi) continue; + if (Math.abs(n.t - t) > tol) continue; + const key = noteKey(n.t, n.midi); + if (hitKeys.has(key)) continue; + return key; + } + return null; + } + + // Missed-note sweep (piano _updateMissedNotes, rebuilt on a monotonic + // cursor): walk notes whose window has fully elapsed and mark unhit + // ones as missed. `cursor` ({idx}) makes the sweep O(elapsed) and + // stall-proof — unlike a fixed look-back tail, a backgrounded tab or a + // multi-second render hitch can't let elapsed notes slip past + // uncounted (which would inflate accuracy for the rest of the run). + // `floor` excludes notes that elapsed before a MIDI device was + // connected so a mid-song connect doesn't retroactively count + // everything as missed (the cursor still advances past them). Calls + // onMiss(note) for each newly-missed note; returns the count. + function sweepMissed(notes, t, hitKeys, missedKeys, tol, floor, onMiss, cursor) { + if (!Array.isArray(notes)) return 0; + const cutoff = t - tol - 0.05; + let i = cursor ? cursor.idx : 0; + let count = 0; + for (; i < notes.length; i++) { + const n = notes[i]; + if (n.t > cutoff) break; + // Notes at or before the mid-song connect instant must not be + // swept retroactively — a device connecting exactly as an onset + // passes should not count that onset as a miss. + if (floor != null && n.t <= floor) continue; + const key = noteKey(n.t, n.midi); + if (hitKeys.has(key) || missedKeys.has(key)) continue; + missedKeys.add(key); + count += 1; + if (onMiss) onMiss(n); + } + if (cursor) cursor.idx = i; + return count; + } + + /* ====================================================================== + * Notation fetch — private per-instance WS (Staff View pattern) + * ====================================================================== */ + + function fetchNotation(filename, arrangementIndex) { + return new Promise((resolve, reject) => { + let info = null; + const measures = []; + const url = (location.protocol === 'https:' ? 'wss://' : 'ws://') + + location.host + '/ws/highway/' + encodeURIComponent(filename) + + '?arrangement=' + encodeURIComponent(arrangementIndex); + let settled = false; + const ws = new WebSocket(url); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + try { ws.close(); } catch (_) {} + reject(new Error('notation stream timed out')); + }, NOTATION_CHUNK_TIMEOUT_MS); + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + try { ws.close(); } catch (_) {} + resolve({ info, measures }); + }; + ws.onmessage = (ev) => { + let msg = null; + try { msg = JSON.parse(ev.data); } catch (_) { return; } + if (!msg || typeof msg !== 'object') return; + if (msg.error) { + if (!settled) { + settled = true; + clearTimeout(timer); + try { ws.close(); } catch (_) {} + reject(new Error(String(msg.error))); + } + return; + } + if (msg.type === 'song_info' && !msg.has_notation) { + // Nothing to stream for this arrangement. + finish(); + return; + } + if (msg.type === 'notation_info') { + info = msg; + if (!msg.total) finish(); + return; + } + if (msg.type === 'notation_measures') { + for (const m of Array.isArray(msg.data) ? msg.data : []) measures.push(m); + if (info && measures.length >= (Number(msg.total) || 0)) finish(); + return; + } + // `anchors` streams right after the notation block — if we see + // it, the notation section is over regardless of count. + if (msg.type === 'anchors' && info) finish(); + }; + ws.onerror = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + reject(new Error('notation stream socket error')); + }; + ws.onclose = () => { if (info) finish(); }; + }); + } + + /* ====================================================================== + * Texture helpers (CanvasTexture — lazy, cached, never per-frame) + * ====================================================================== */ + + // Key-top letter textures, cached module-level by letter+style. Bounded + // (12 pitch classes × 2 styles) and shared across factory instances; + // CanvasTexture GPU copies are re-uploaded transparently after a renderer + // dispose, so the cache survives teardown safely. + const _glyphTexCache = new Map(); + function _glyphTexture(letter, dark) { + const key = letter + '|' + (dark ? 'd' : 'l'); + let tex = _glyphTexCache.get(key); + if (tex) return tex; + const cnv = document.createElement('canvas'); + cnv.width = 96; + cnv.height = 96; + const ctx = cnv.getContext('2d'); + ctx.clearRect(0, 0, 96, 96); + ctx.font = '600 48px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = dark ? '#23232e' : '#e8e8f2'; + ctx.fillText(letter, 48, 50); + tex = new T.CanvasTexture(cnv); + tex.anisotropy = 4; + _glyphTexCache.set(key, tex); + return tex; + } + + /* ====================================================================== + * Web MIDI input (module-scope singleton — one MIDI access per tab) + * + * Port of the piano plugin's MIDI block with drum_highway_3d's 3D-plugin + * refinements: id+name saved pick (Chrome regenerates ids per load), + * loopback blocklist, init-in-flight dedupe, and the _midiActive gate so + * an access promise resolving after destroy() can't wire a dead handler. + * ====================================================================== */ + + function _readStore(k) { + try { return localStorage.getItem(k); } catch (_) { return null; } + } + function _writeStore(k, v) { + try { localStorage.setItem(k, v); } catch (_) {} + } + + const _cfg = { + midiChannel: parseInt(_readStore(STORE_KEYS.midiChannel) || '-1', 10), + transpose: parseInt(_readStore(STORE_KEYS.transpose) || '0', 10), + }; + if (!Number.isFinite(_cfg.midiChannel)) _cfg.midiChannel = -1; + if (!Number.isFinite(_cfg.transpose)) _cfg.transpose = 0; + + // MIDI is sourced from the core `midi-input` capability domain + // (window.slopsmith.midiInput) rather than a private requestMIDIAccess() — + // one device-access boundary shared with piano/drums/onboarding. + let _midiReady = false; // discover() has run + let _midiHandle = null; // live domain session handle (addListener/removeListener) + let _midiListener = null; // addListener callback wrapping _midiOnMessage + let _midiStateSub = false; // subscribed to midi-input:sources-changed + let _midiInput = null; // selected source descriptor { id, name } + // Set by _midiConnect; the focused instance reads + clears it to skip + // retroactive miss-counting for notes that elapsed while disconnected. + let _midiJustConnected = false; + // Gates the live listener wiring across the async connect. + let _midiActive = false; + // Routes incoming MIDI to the focused renderer instance. + let _activeInstance = null; + const _instances = new Set(); + let _midiInitInFlight = null; + let _midiConnectSeq = 0; // generation guard for async _midiConnect races + + // The core midi-input domain, if present (it ships with core). + function _mi() { + const m = window.slopsmith && window.slopsmith.midiInput; + return (m && m.version === 1) ? m : null; + } + // Domain sources shaped like the old MIDIInput list: { id, name }. + // sourceId == the old MIDIInput.id, so saved picks stay compatible. + function _midiSources() { + const mi = _mi(); + if (!mi) return []; + return mi.listSources().map(s => ({ id: s.sourceId, name: s.label, key: s.logicalSourceKey })); + } + // Detach the live listener + release the domain session. + function _midiDetach() { + // Invalidate any in-flight _midiConnect open: a detach driven by device + // removal (sources-changed) or an opt-out must supersede a pending open + // so it can't resume and install a handle for a now-gone source. + _midiConnectSeq += 1; + if (_midiHandle && _midiListener) { try { _midiHandle.removeListener(_midiListener); } catch (_) { /* best-effort */ } } + const mi = _mi(); + if (mi && _midiInput) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: _midiInput.key || ('web-midi::' + _midiInput.id) }); } catch (_) { /* best-effort */ } } + _midiHandle = null; + _midiListener = null; + _midiInput = null; + } + + function _midiInit() { + if (_midiReady) { + // Only (re)connect when there's no live session. A repeated init + // (settings panel open, extra splitscreen instance) must NOT re-enter + // _midiConnect on an active handle — that tears down the live session + // and releases held keys for no reason. After a full release the + // handle is null, so reconnect happens then. + if (!_midiHandle) _midiAutoConnect(); + return Promise.resolve(); + } + if (_midiInitInFlight) return _midiInitInFlight; + const mi = _mi(); + if (!mi) return Promise.resolve(); + _midiInitInFlight = (async () => { + try { + const r = await mi.discover(); // permission boundary (requestMIDIAccess, in core) + // Only latch ready on a successful discovery — a denied/unavailable + // outcome must NOT latch, or reopening never retries the prompt. + if (!r || r.outcome !== 'handled') return; + _midiReady = true; + // Replug/unplug refresh (replaces MIDIAccess.onstatechange): the + // domain re-discovers and emits sources-changed; refresh the list + // and re-run auto-connect so a saved device reattaches. + if (!_midiStateSub && window.slopsmith && typeof window.slopsmith.on === 'function') { + _midiStateSub = true; + window.slopsmith.on('midi-input:sources-changed', () => { + _midiNotifyDeviceListChanged(); + if (!_midiInput) _midiAutoConnect(false); // recovery: saved device only + }); + } + _midiAutoConnect(); + _midiNotifyDeviceListChanged(); + } catch (e) { + console.warn('[Keys-Hwy3D] MIDI access denied:', e); + } finally { + _midiInitInFlight = null; + } + })(); + return _midiInitInFlight; + } + + function _readSavedPick() { + try { + const raw = _readStore(STORE_KEYS.midiPick); + if (raw) { + const obj = JSON.parse(raw); + if (obj && typeof obj === 'object') { + return { id: String(obj.id || ''), name: String(obj.name || ''), key: String(obj.key || '') }; + } + } + } catch (_) {} + return null; + } + + function _writeSavedPick(id, name, key) { + _writeStore(STORE_KEYS.midiPick, JSON.stringify({ id: id || '', name: name || '', key: key || '' })); + } + + function _midiAutoConnect(allowFallback) { + // Recovery (sources-changed after unplug) passes false: never switch to a + // fallback input, because _midiConnect persists the pick and that would + // overwrite the user's saved device on a transient multi-device unplug + // (the original returns on replug and reconnects then). + if (allowFallback === undefined) allowFallback = true; + const inputs = _midiSources(); + if (!inputs.length) return; + const saved = _readSavedPick(); + // Explicit "None" opt-out. + if (saved && saved.id === '' && saved.name === '') return; + // Prefer the globally-unique logicalSourceKey, then the legacy bare + // sourceId, then case-insensitive name (Chrome on Linux regenerates ids + // per page load), then first non-loopback. + let target = null; + if (saved && saved.key) target = inputs.find(i => i.key === saved.key) || null; + if (!target && saved && saved.id) target = inputs.find(i => i.id === saved.id) || null; + if (!target && saved && saved.name) { + const n = saved.name.toLowerCase(); + target = inputs.find(i => (i.name || '').toLowerCase() === n) || null; + } + // Never honour a saved pick that's a loopback / "Midi Through" port — it + // carries no device input, so a stale pick silently eats every note. The + // saved-pick lookups above bypass the block-list; re-apply it here. + if (target && _MIDI_BLOCKLIST_RE.test(target.name || '')) target = null; + if (!target) { + // Skip the substitute ONLY when a saved pick exists but is currently + // absent (recovery: preserve it, don't clobber on a transient unplug). + // With no saved pick at all, a fallback is the intended first-hotplug + // auto-connect — allow it even in recovery. + const hasSavedPick = !!(saved && (saved.key || saved.id || saved.name)); + if (!allowFallback && hasSavedPick) return; + target = inputs.find(i => !_MIDI_BLOCKLIST_RE.test(i.name || '')) || inputs[0]; + } + _midiConnect(target.id, target.name, target.key); + } + + async function _midiConnect(id, name, key) { + // Capture our generation AFTER _midiDetach()'s own bump, so a later + // detach (device removal / new connect / opt-out) reliably supersedes us. + _midiDetach(); + const myGen = ++_midiConnectSeq; + // Connecting (or opting out) invalidates per-instance held state — + // clear EVERY live instance, not just the focused one, so no panel + // shows stuck keys when it later takes focus (piano-plugin lesson). + for (const inst of _instances) { + if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld(); + } + _writeSavedPick(id || '', name || '', key || ''); + const mi = _mi(); + if ((id || key) && mi) { + // Prefer the globally-unique logicalSourceKey so two providers that + // expose the same provider-local sourceId stay distinguishable; fall + // back to the legacy sourceId match. + const src = (key && _midiSources().find(s => s.key === key)) + || (id && _midiSources().find(s => s.id === id)) + || null; + if (src) { + const lkey = src.key || ('web-midi::' + src.id); + _midiInput = { id: src.id, name: src.name, key: lkey }; + _midiJustConnected = true; + // No live renderer to consume OR release a session — don't hold one + // open (settings-only ensure-init, or the last instance was torn + // down during async discovery). The pick is saved; a later renderer + // mount re-runs auto-connect and opens for real, releasing on destroy. + if (_instances.size === 0) { _midiNotifyDeviceListChanged(); return; } + try { + await mi.select(lkey); + const res = await mi.open({ requester: PLUGIN_ID, logicalSourceKey: lkey }); + // A newer _midiConnect (device switch / None / replug) ran while + // we awaited open — discard this stale session so we don't wire a + // listener for a device the user already moved off of. + if (myGen !== _midiConnectSeq) { + if (!_midiInput || _midiInput.key !== lkey) { try { mi.close({ requester: PLUGIN_ID, logicalSourceKey: lkey }); } catch (_) { /* best-effort */ } } + return; + } + if (res && res.handle) { + _midiHandle = res.handle; + // The domain handle delivers raw MIDI data; adapt to the + // old MIDIMessageEvent shape so _midiOnMessage is unchanged. + _midiListener = (data) => _midiOnMessage({ data }); + if (_midiActive) _midiHandle.addListener(_midiListener); + } else { + // Open yielded no live handle (device vanished post-discovery, + // or denied/unavailable). Clear the selection so the render + // loop's connected-device gate doesn't sweep phantom misses. + _midiInput = null; + } + } catch (e) { + console.warn('[Keys-Hwy3D] MIDI open failed:', e); + // Only clear if we're still the current connect — a stale older + // open's rejection must not wipe a newer connect's installed + // _midiInput/_midiHandle (which would also leak the live handle, + // since closes are gated on _midiInput). + if (myGen === _midiConnectSeq) _midiInput = null; + } + } + } + _midiNotifyDeviceListChanged(); + } + + function _midiResume() { + // Idempotent: a second live renderer instance (splitscreen/overlapping + // lifetimes) calls this while already active. The domain handle's + // addListener is Set-backed, but don't rely on the provider de-duping — + // re-adding here could double-deliver one MIDI note to the focused + // instance and score a hit plus duplicate misses. + if (_midiActive) return; + _midiActive = true; + if (_midiHandle && _midiListener) { try { _midiHandle.addListener(_midiListener); } catch (_) { /* best-effort */ } } + } + // Called when the LAST live instance is torn down: fully release the shared + // midi-input domain session (via _midiDetach: close + null + generation bump), + // not just the listener, so the device/provider session isn't held open after + // the visualization is gone. Re-mount's _midiInit auto-connects from the saved + // pick, so _midiReady is intentionally left latched. + function _midiReleaseSession() { + _midiActive = false; + _midiDetach(); + } + + function _midiOnMessage(e) { + if (!_activeInstance) return; + const data = e.data; + if (!data || data.length < 2) return; + const status = data[0]; + const ch = status & 0x0f; + if (_cfg.midiChannel >= 0 && ch !== _cfg.midiChannel) return; + const cmd = status & 0xf0; + const note = data[1]; + const vel = data.length > 2 ? data[2] : 0; + // Raw note number goes to the instance; transpose is applied there + // and remembered per note-on so a transpose change between note-on + // and note-off can't strand a held key (piano-plugin invariant). + if (cmd === 0x90 && vel > 0) { + _activeInstance._handleNoteOn(note, vel); + } else if (cmd === 0x80 || (cmd === 0x90 && vel === 0)) { + _activeInstance._handleNoteOff(note); + } else if (cmd === 0xb0 && note === 64) { + _activeInstance._handleSustain(vel >= 64); // CC64 sustain pedal + } + } + + function _midiNotifyDeviceListChanged() { + // Clear a vanished selection so miss-sweeping stops. No note-off + // can ever arrive for keys that were down when the device went + // away, so clear held state on every instance too — otherwise + // those keys stay visually depressed for the rest of the session. + if (_midiInput && !_midiSources().some(s => s.id === _midiInput.id)) { + _midiDetach(); + for (const inst of _instances) { + if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld(); + } + } + _aiRefreshSources(); + try { window.dispatchEvent(new CustomEvent('keys3d:midi_devices')); } catch (_) {} + } + + function _midiListInputs() { + return _midiSources().map(s => ({ id: s.id, name: s.name || s.id })); + } + + /* ── Built-in synth — hear your playing (WebAudio, offline-safe, no CDN) ── */ + let _synthCtx = null, _synthMaster = null; + const _synthVoices = new Map(); // midi → { o1, o2, g } + function _synthResume() { + if (!_synthCtx) { + try { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + _synthCtx = new AC(); + _synthMaster = _synthCtx.createGain(); + _synthMaster.gain.value = 0.45; + _synthMaster.connect(_synthCtx.destination); + } catch (_) { _synthCtx = null; return; } + } + if (_synthCtx.state === 'suspended') _synthCtx.resume().catch(() => {}); + } + // Autoplay policy: an AudioContext can only start from a real user gesture + // (a MIDI event doesn't count), so prime/resume it on any page input. + // Guard addEventListener — the test harness provides a bare vm window + // without DOM event support, and the synth is never invoked there. + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + ['pointerdown', 'keydown'].forEach(ev => + window.addEventListener(ev, _synthResume, { passive: true })); + } + function _synthNoteOn(midi, vel) { + _synthResume(); + if (!_synthCtx || _synthCtx.state !== 'running') return; + if (_synthVoices.has(midi)) _synthNoteOff(midi); + const now = _synthCtx.currentTime; + const f = 440 * Math.pow(2, (midi - 69) / 12); + const o1 = _synthCtx.createOscillator(); o1.type = 'triangle'; o1.frequency.value = f; + const o2 = _synthCtx.createOscillator(); o2.type = 'sine'; o2.frequency.value = f; o2.detune.value = -6; + const lp = _synthCtx.createBiquadFilter(); lp.type = 'lowpass'; lp.frequency.value = Math.min(9000, f * 7); + const g = _synthCtx.createGain(); + const peak = 0.12 + 0.16 * (Math.max(1, Math.min(127, vel || 96)) / 127); + g.gain.setValueAtTime(0.0001, now); + g.gain.exponentialRampToValueAtTime(peak, now + 0.006); // attack + g.gain.exponentialRampToValueAtTime(peak * 0.55, now + 0.35); // decay → sustain + o1.connect(lp); o2.connect(lp); lp.connect(g); g.connect(_synthMaster); + o1.start(now); o2.start(now); + _synthVoices.set(midi, { o1, o2, g }); + } + function _synthNoteOff(midi) { + const v = _synthVoices.get(midi); + if (!v || !_synthCtx) return; + _synthVoices.delete(midi); + const now = _synthCtx.currentTime; + try { + v.g.gain.cancelScheduledValues(now); + v.g.gain.setValueAtTime(Math.max(0.0001, v.g.gain.value), now); + v.g.gain.exponentialRampToValueAtTime(0.0001, now + 0.22); // release + v.o1.stop(now + 0.25); v.o2.stop(now + 0.25); + } catch (_) {} + } + function _synthAllOff() { + for (const midi of Array.from(_synthVoices.keys())) _synthNoteOff(midi); + } + + /* ── Device/settings control API (settings UI + console) ───────────── */ + window.keysH3dEnsureMidiInit = function () { return _midiInit(); }; + window.keysH3dListMidiInputs = function () { return _midiListInputs(); }; + window.keysH3dGetMidiInputId = function () { return _midiInput ? _midiInput.id : ''; }; + window.keysH3dSetMidiInput = function (id) { + // `id` may be a logicalSourceKey (new host calls) or a legacy sourceId. + const src = id + ? (_midiSources().find(s => s.key === id) || _midiSources().find(s => s.id === id)) + : null; + _midiConnect(src ? src.id : (id || ''), src ? src.name : '', src ? src.key : ''); + return true; + }; + window.keysH3dGetMidiChannel = function () { return _cfg.midiChannel; }; + window.keysH3dSetMidiChannel = function (ch) { + const v = parseInt(ch, 10); + _cfg.midiChannel = Number.isFinite(v) ? Math.max(-1, Math.min(15, v)) : -1; + _writeStore(STORE_KEYS.midiChannel, String(_cfg.midiChannel)); + }; + window.keysH3dGetTranspose = function () { return _cfg.transpose; }; + window.keysH3dSetTranspose = function (semis) { + const v = parseInt(semis, 10); + _cfg.transpose = Number.isFinite(v) ? Math.max(-24, Math.min(24, v)) : 0; + _writeStore(STORE_KEYS.transpose, String(_cfg.transpose)); + }; + + /* ====================================================================== + * Capability wiring — note-detection + audio-input domains + * + * Everything here is guarded: on servers without the capability hosts + * (the spec-009 note-detection owner / the audio-session input owner) + * every call degrades to a silent no-op. + * ====================================================================== */ + + function _capsApi() { + const c = window.slopsmith && window.slopsmith.capabilities; + return (c && c.version === 1 && typeof c.command === 'function') ? c : null; + } + + async function _capCommand(domain, name, payload, reason) { + const caps = _capsApi(); + if (!caps) return null; + try { + const r = await caps.command(domain, name, { + requester: PLUGIN_ID, + source: PLUGIN_ID, + origin: 'system', + reason: reason || ('Keys Highway 3D ' + domain + '.' + name), + payload: payload || {}, + }); + return (r && r.outcome === 'handled') ? (r.payload || {}) : null; + } catch (_) { return null; } + } + + // ── note-detection: this plugin is a Web-MIDI exact-verdict provider ── + let _ndProviderRegistered = false; + async function _ndEnsureProvider() { + if (_ndProviderRegistered) return; + const p = await _capCommand('note-detection', 'register-provider', { + providerId: ND_PROVIDER_ID, + label: 'Keys Highway MIDI', + kind: 'midi', + primitives: ['verify.target'], + }, 'Register the Web MIDI keys detector'); + if (p) _ndProviderRegistered = true; + } + + // Hit/miss observability events (consumers own judgment; the domain + // only carries the result — spec 009 doctrine). + function _ndReport(hit, midi, bindingId) { + const nd = window.slopsmith && window.slopsmith.noteDetection; + if (!nd || nd.version !== 1) return; + try { + (hit ? nd.reportHit : nd.reportMiss)({ + bindingId: bindingId || null, + providerId: ND_PROVIDER_ID, + midi, + hit, + }); + } catch (_) { /* observability must never break scoring */ } + } + + // ── audio-input: enumerate Web MIDI inputs as input sources ───────── + // + // Labels are PSEUDONYMIZED ('midi-input-1', …) — device names never + // leave the plugin (the audio-input contract surfaces labels verbatim + // in redaction-safe diagnostics). + let _aiRegisteredCount = 0; + + function _aiSourceSpec(index) { + const n = index + 1; + return { + version: 1, + providerId: ND_PROVIDER_ID, + ownerPluginId: PLUGIN_ID, + sourceId: ND_PROVIDER_ID + ':input-' + n, + logicalSourceKey: ND_PROVIDER_ID + ':input-' + n, + label: 'midi-input-' + n, + labelSafe: true, + kind: 'instrument', + availability: 'available', + operations: ['source.enumerate', 'source.describe', 'source.open', 'source.close'], + operationHandlers: { + 'source.enumerate': _aiEnumerate, + 'source.describe': _aiDescribe, + 'source.open': _aiOpen, + 'source.close': _aiClose, + }, + }; + } + + function _aiEnumerate() { + const sources = _midiListInputs().map((_inp, i) => { + const spec = _aiSourceSpec(i); + delete spec.operationHandlers; // host re-merges handlers per provider + return spec; + }); + return { outcome: 'handled', payload: { sources } }; + } + + function _aiDescribe(req) { + const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey)); + const inputs = _midiListInputs(); + if (idx == null || idx >= inputs.length) { + return { outcome: 'degraded', reason: 'Unknown MIDI input source' }; + } + const spec = _aiSourceSpec(idx); + delete spec.operationHandlers; + return { outcome: 'handled', payload: { source: spec } }; + } + + function _aiIndexFor(key) { + const m = /:input-(\d+)$/.exec(String(key || '')); + if (!m) return null; + const idx = parseInt(m[1], 10) - 1; + return (Number.isFinite(idx) && idx >= 0) ? idx : null; + } + + function _aiOpen(req) { + // Opening a MIDI source connects the corresponding Web MIDI input. + const idx = _aiIndexFor(req && (req.sourceId || req.logicalSourceKey)); + const inputs = _midiSources(); // carries .key (logicalSourceKey), unlike _midiListInputs() + if (idx == null || idx >= inputs.length) { + return { outcome: 'failed', reason: 'MIDI input source is no longer present' }; + } + _midiConnect(inputs[idx].id, inputs[idx].name, inputs[idx].key); + return { outcome: 'handled', payload: {} }; + } + + function _aiClose() { + // Closing the session must actually stop note events flowing — + // detach the input WITHOUT persisting an opt-out (the saved device + // pick is user state, not session state, so a later open or a + // device statechange can re-attach it). + if (_midiInput || _midiHandle) { + _midiDetach(); + // No note-off can ever arrive for keys that were down at close, + // so release held/sustained state on every instance — otherwise + // those keys stay visually depressed until a later reconnect or + // teardown. (_midiNotifyDeviceListChanged only releases when the + // device VANISHED from the source list, which a deliberate + // close is not, so we release explicitly here.) + for (const inst of _instances) { + if (inst && typeof inst._releaseAllHeld === 'function') inst._releaseAllHeld(); + } + _midiNotifyDeviceListChanged(); + } + return { outcome: 'handled', payload: {} }; + } + + function _aiRefreshSources() { + // MIDI is no longer surfaced into the audio-input domain — keys MIDI now + // lives in the dedicated midi-input domain. Exporting pseudonymized + // 'midi-input-N' sources here polluted audio-input device pickers (e.g. + // the onboarding guitar input dropdown) with non-audio entries. Drop any + // left over from an older build, and register none going forward. + if (!_capsApi()) return; + // Iterate a fixed bound over the KNOWN sourceId pattern, not a module-local + // counter: after an in-page upgrade _aiRegisteredCount is reset to 0, so a + // count-based loop would skip the prior build's leftovers entirely. The + // sourceId/logicalSourceKey are the real (unpseudonymized) keys this plugin + // registered, so unregister resolves them directly; an absent source is a + // harmless no-op. 32 comfortably exceeds any realistic MIDI input count. + const MAX_LEGACY_MIDI_SOURCES = 32; + for (let n = 1; n <= MAX_LEGACY_MIDI_SOURCES; n++) { + const key = ND_PROVIDER_ID + ':input-' + n; + _capCommand('audio-input', 'unregister-source', + { providerId: ND_PROVIDER_ID, sourceId: key, logicalSourceKey: key }, + 'MIDI no longer exported to audio-input'); + } + _aiRegisteredCount = 0; + } + + /* ====================================================================== + * Renderer factory + * ====================================================================== */ + + function createFactory() { + let highwayCanvas = null; + let ren = null, scene = null, cam = null; + let _camX = 0, _camTargetX = 0; // camera x pan-follow state (updateScene) + let _camZoom = 1, _camTargetZoom = 1; // adaptive dolly-zoom to the note span + let notesGroup = null, keyboardGroup = null, markersGroup = null, hitLine = null; + let noteMeshes = []; // [{mesh, note, len}] + let markerSprites = []; // [{sprite, t}] + let keyMeshes = new Map(); // midi → mesh + let _isReady = false; + let _notation = null; // {notes, range, markers} + let _loadSeq = 0; + let _songHandler = null; + // Per-chart geometry/material caches. One bevelled geometry per + // (width, length-bucket) and one material per (pitch class, hand) + // keeps per-note cost down to a mesh; rebuilt per chart, disposed on + // rebuild + teardown. + const _noteGeoCache = new Map(); + const _noteMatCache = new Map(); + const _barTexCache = new Map(); // measure idx → CanvasTexture + let _glowTex = null; // hit-line gradient (lazy, reused) + const _hitGlowMats = []; // additive glow materials pulsed in draw() + + // ── MIDI scoring + live feedback state ────────────────────────── + let _layoutInfo = null; // {layout, whiteCount} of current chart + let _hits = 0, _misses = 0, _streak = 0, _bestStreak = 0; + const _hitNoteKeys = new Set(); // noteKey(t, midi) of hit chart notes + const _missedNoteKeys = new Set(); // …and swept-missed ones + const _sweepCursor = { idx: 0 }; // monotonic miss-sweep position + let _latestTime = 0; // song time from the last draw bundle + let _missFloor = null; // no retroactive misses before this t + const _rawToPlayed = new Map(); // raw midi → transposed midi (held) + const _heldNotes = new Set(); // transposed midis currently down + const _sustainedNotes = new Set(); // released while CC64 held + let _sustainOn = false; + const _keyAnim = new Map(); // midi → true while a key is animating + const _keyFlash = new Map(); // midi → wall ms of wrong-note flash + let _lastWallMs = 0; // wall clock of the previous draw + let _runMeta = null; // {filename, arrangement} of this run + let _recordedThisRun = false; // /api/stats posted exactly once + let _ndBindingId = null; // open note-detection binding + // Flame pool (built with the scene, reused for every hit). + let _flamesGroup = null; + const _flamePool = []; // [{sprite, mat, start, baseY}] + let _flameIdx = 0; + const _flameTexCache = new Map(); // pitch class → CanvasTexture + let _endHandler = null, _stopHandler = null; + + function _emitDomain(event, payload) { + const caps = window.slopsmith && window.slopsmith.capabilities; + if (caps && caps.version === 1 && typeof caps.emitEvent === 'function') { + try { caps.emitEvent('visualization', event, payload || {}); } catch (_) {} + } + } + + function isBlackKey(midi) { + return [1, 3, 6, 8, 10].includes(((midi % 12) + 12) % 12); + } + + function noteColor(midi, hand) { + const base = new T.Color(PITCH_CLASS_COLORS[((midi % 12) + 12) % 12]); + const b = HAND_BRIGHTNESS[hand] != null ? HAND_BRIGHTNESS[hand] : 1.0; + base.multiplyScalar(b); + return base; + } + + // X position of a key's centre. White keys advance one slot each; + // black keys sit between, narrower and raised. + function keyLayout(range) { + const layout = new Map(); + let whiteIndex = 0; + for (let midi = range.low; midi <= range.high; midi++) { + if (isBlackKey(midi)) continue; + layout.set(midi, { slot: whiteIndex, black: false }); + whiteIndex += 1; + } + for (let midi = range.low; midi <= range.high; midi++) { + if (!isBlackKey(midi)) continue; + const left = layout.get(midi - 1); + layout.set(midi, { slot: left ? left.slot + 0.5 : 0, black: true }); + } + return { layout, whiteCount: whiteIndex }; + } + + const WHITE_W = 12 * K, WHITE_L = 46 * K, WHITE_H = 5 * K; + const BLACK_W = 6.4 * K, BLACK_L = 28 * K, BLACK_H = 6.5 * K; + const HIGHWAY_LEN = 1150 * K; // longer runway → ~8.8s of lookahead visible + + // Camera — low, near-telephoto rig (RS+-style): a narrow FOV from low + // and back gives a deep receding runway and frames ~2 octaves instead + // of cramming the whole note range full-width. The x position pans to + // follow the active notes (see updateScene), so wide pieces stay zoomed + // in on the played hand rather than shrinking every key. + const CAM_FOV = 40; + const CAM_Y = 46 * K, CAM_Z = 112 * K; + const LOOK_Y = 8 * K, LOOK_Z = -165 * K; + // Pan-follow: a slow ease toward a wide, gently-weighted centroid so the + // camera glides with the melody instead of darting as notes enter/leave. + const CAM_PAN_LERP = 0.022; // per-frame ease (~1s glide @60fps) + // Adaptive dolly-zoom: frame the FULL pitch span of the incoming notes and + // centre on its MIDPOINT, so both the lowest (LH) and highest (RH) target + // keys stay on the keyboard in view. Pull back for wide spreads (both + // hands), push in for tight passages. Camera dollies along its view ray + // (offset × zoom). BASE_KEYS is calibrated at the keyboard plane (the + // binding constraint — it sits in a narrower cone than the far notes), so + // framing the key span here keeps the target keys visible, not just the notes. + const CAM_ZOOM_AHEAD = 3.5; // seconds of notes considered for framing + const CAM_ZOOM_PAD = 9 * WHITE_W;// total margin beyond the span (~4.5 keys/side) + const CAM_ZOOM_BASE_KEYS = 11; // white keys framed at zoom = 1 + const CAM_ZOOM_MIN = 0.9, CAM_ZOOM_MAX = 4.8; + const CAM_ZOOM_LERP = 0.025; // smooth zoom ease + // Per-key approach glow: a key lights in its pitch-class colour ONLY while a + // note is heading for it, ramping up the closer that note gets to the hit-line. + const KEY_GLOW_AHEAD = 2.0; // seconds before the hit-line a key starts to light + const KEY_GLOW_STRENGTH = 1.15; // peak emissive intensity (note at the hit-line) + + function keyX(layoutEntry, whiteCount) { + return (layoutEntry.slot - (whiteCount - 1) / 2) * WHITE_W; + } + + function buildScene() { + scene = new T.Scene(); + scene.fog = new T.Fog(FOG_COLOR, FOG_START, FOG_END); + + cam = new T.PerspectiveCamera(CAM_FOV, 1, 0.1, 2000 * K); + _camX = 0; _camTargetX = 0; _camZoom = 1; _camTargetZoom = 1; + cam.position.set(0, CAM_Y, CAM_Z); + cam.lookAt(0, LOOK_Y, LOOK_Z); + + const ambient = new T.AmbientLight(0xffffff, 0.75); + const dir = new T.DirectionalLight(0xffffff, 1.1); + dir.position.set(60 * K, 200 * K, 80 * K); + scene.add(ambient, dir); + + keyboardGroup = new T.Group(); + notesGroup = new T.Group(); + markersGroup = new T.Group(); + _flamesGroup = new T.Group(); + scene.add(keyboardGroup, notesGroup, markersGroup, _flamesGroup); + _buildFlamePool(); + } + + // Drop every child of a group (recursively — key glyphs are children + // of their key mesh so depress animation carries them), disposing + // geometry + material. Cached resources (note geometries/materials, + // bar/glyph textures) may be disposed again via their caches — + // three.js dispose() is safe to call more than once. Texture maps + // held by caches are NOT disposed here. + function _disposeDeep(obj) { + while (obj.children && obj.children.length) { + _disposeDeep(obj.children.pop()); + } + if (obj.geometry) obj.geometry.dispose(); + if (obj.material && obj.material.dispose) obj.material.dispose(); + } + function _clearGroup(group) { + if (!group) return; + while (group.children.length) { + _disposeDeep(group.children.pop()); + } + } + + function _clearNoteCaches() { + for (const g of _noteGeoCache.values()) g.dispose(); + _noteGeoCache.clear(); + for (const m of _noteMatCache.values()) m.dispose(); + _noteMatCache.clear(); + } + + function _clearBarTextures() { + for (const t of _barTexCache.values()) t.dispose(); + _barTexCache.clear(); + } + + // Bevelled "gem" geometry for a note block: a rectangular cross- + // section (w × NOTE_H) extruded along Z for the note's length with a + // small bevel, so edges catch the light. Cached per (width, + // length-bucket) — lengths quantised to 0.01 world units so charts + // share geometries instead of allocating one per note. + // Rounded-rectangle shape (centred) — reused for gem cross-sections and + // rounded key footprints. + function _roundedRectShape(w, h, r) { + r = Math.max(0, Math.min(r, w / 2 - 0.001, h / 2 - 0.001)); + const hw = w / 2, hh = h / 2, s = new T.Shape(); + s.moveTo(-hw + r, -hh); + s.lineTo(hw - r, -hh); + s.quadraticCurveTo(hw, -hh, hw, -hh + r); + s.lineTo(hw, hh - r); + s.quadraticCurveTo(hw, hh, hw - r, hh); + s.lineTo(-hw + r, hh); + s.quadraticCurveTo(-hw, hh, -hw, hh - r); + s.lineTo(-hw, -hh + r); + s.quadraticCurveTo(-hw, -hh, -hw + r, -hh); + return s; + } + + // Rounded box (w×h×d, centred) — rounded-rect footprint extruded along + // height with a bevel, so vertical corners are rounded and the top/bottom + // edges softened. Core-three only (no RoundedBoxGeometry addon). Used for + // the piano keys. + function _roundedBoxGeo(w, h, d, r, bev) { + bev = Math.max(0.001, Math.min(bev, h / 2 - 0.001)); + const g = new T.ExtrudeGeometry(_roundedRectShape(w, d, r), { + depth: Math.max(h - 2 * bev, 0.001), + bevelEnabled: true, bevelThickness: bev, bevelSize: bev, + bevelSegments: 3, curveSegments: 5, + }); + g.computeBoundingBox(); + const bz = g.boundingBox; + g.translate(0, 0, -(bz.min.z + bz.max.z) / 2); // centre the height axis + g.rotateX(-Math.PI / 2); // stand up: height → +Y + return g; + } + + function _noteGeometry(w, len) { + const key = w.toFixed(4) + '|' + len.toFixed(2); + let geo = _noteGeoCache.get(key); + if (geo) return geo; + const bevel = Math.min(NOTE_BEVEL, w * 0.25, len * 0.25, NOTE_H * 0.35); + const hw = w / 2 - bevel; + const hh = NOTE_H / 2 - bevel; + // Lightly rounded cross-section — soften the long edges without the + // pill/plastic look. + const shape = _roundedRectShape(2 * hw, 2 * hh, Math.min(hw, hh) * 0.3); + const depth = Math.max(len - 2 * bevel, bevel); + geo = new T.ExtrudeGeometry(shape, { + depth, + bevelEnabled: true, + bevelThickness: bevel, + bevelSize: bevel, + bevelSegments: 3, + curveSegments: 5, + }); + // Extrusion spans z ∈ [-bevel, depth + bevel]; centre it. + geo.translate(0, 0, -depth / 2); + // Bake a vertical brightness ramp into vertex colours (bottom shade → + // top highlight) so the gem reads 3D; the material multiplies its + // pitch-class colour by this (vertexColors). + geo.computeBoundingBox(); + const y0 = geo.boundingBox.min.y, yr = (geo.boundingBox.max.y - y0) || 1; + const pos = geo.attributes.position; + const cols = new Float32Array(pos.count * 3); + for (let i = 0; i < pos.count; i++) { + const t = (pos.getY(i) - y0) / yr; // 0 bottom .. 1 top + const v = GEM_SHADE_BOT + (GEM_SHADE_TOP - GEM_SHADE_BOT) * (t * t * (3 - 2 * t)); + cols[i * 3] = cols[i * 3 + 1] = cols[i * 3 + 2] = v; + } + geo.setAttribute('color', new T.BufferAttribute(cols, 3)); + _noteGeoCache.set(key, geo); + return geo; + } + + // Glossy note material, cached per (pitch class, hand). + function _noteMaterial(midi, hand) { + const handKey = HAND_BRIGHTNESS[hand] != null ? hand : 'rh'; + const key = (((midi % 12) + 12) % 12) + '|' + handKey; + let mat = _noteMatCache.get(key); + if (mat) return mat; + const col = noteColor(midi, hand); + mat = new T.MeshStandardMaterial({ + color: col, + vertexColors: true, // multiply colour by the baked gem ramp + emissive: col, + emissiveIntensity: NOTE_EMISSIVE_BASE, + roughness: 0.78, // matte — kills the glossy "plastic" highlight + metalness: 0.0, + transparent: true, + opacity: 0.8, // see the keys through the notes + }); + _noteMatCache.set(key, mat); + return mat; + } + + function _barNumberTexture(idx) { + let tex = _barTexCache.get(idx); + if (tex) return tex; + const cnv = document.createElement('canvas'); + cnv.width = 128; + cnv.height = 64; + const ctx = cnv.getContext('2d'); + ctx.clearRect(0, 0, 128, 64); + ctx.font = '600 38px system-ui, sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = 'rgba(232,232,242,0.88)'; + ctx.fillText(String(idx), 64, 34); + tex = new T.CanvasTexture(cnv); + _barTexCache.set(idx, tex); + return tex; + } + + // Vertical gradient for the hit-line glow: white-hot core fading to + // transparent cyan at the edges. Lazy, reused for both glow planes. + function _glowTexture() { + if (_glowTex) return _glowTex; + const cnv = document.createElement('canvas'); + cnv.width = 16; + cnv.height = 64; + const ctx = cnv.getContext('2d'); + const grad = ctx.createLinearGradient(0, 0, 0, 64); + grad.addColorStop(0.0, 'rgba(64,255,208,0)'); + grad.addColorStop(0.5, 'rgba(224,255,248,1)'); + grad.addColorStop(1.0, 'rgba(64,255,208,0)'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 16, 64); + _glowTex = new T.CanvasTexture(cnv); + return _glowTex; + } + + // Vertical flame texture for hit flares: white-hot base fading up + // into the pitch-class colour, with a horizontal falloff. Cached per + // pitch class (bounded, 12 entries). + function _flameTexture(pc) { + let tex = _flameTexCache.get(pc); + if (tex) return tex; + const c = PITCH_CLASS_COLORS[pc]; + const r = (c >> 16) & 0xff, g = (c >> 8) & 0xff, b = c & 0xff; + const cnv = document.createElement('canvas'); + cnv.width = 64; + cnv.height = 128; + const ctx = cnv.getContext('2d'); + const grad = ctx.createLinearGradient(0, 128, 0, 0); // bottom → top + grad.addColorStop(0.0, 'rgba(255,255,255,1)'); // white-hot base + grad.addColorStop(0.3, `rgba(${r},${g},${b},0.95)`); + grad.addColorStop(1.0, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, 64, 128); + const falloff = ctx.createLinearGradient(0, 0, 64, 0); + falloff.addColorStop(0.0, 'rgba(0,0,0,0)'); + falloff.addColorStop(0.5, 'rgba(0,0,0,1)'); + falloff.addColorStop(1.0, 'rgba(0,0,0,0)'); + ctx.globalCompositeOperation = 'destination-in'; + ctx.fillStyle = falloff; + ctx.fillRect(0, 0, 64, 128); + tex = new T.CanvasTexture(cnv); + _flameTexCache.set(pc, tex); + return tex; + } + + function _clearFlameTextures() { + for (const t of _flameTexCache.values()) t.dispose(); + _flameTexCache.clear(); + } + + // Pooled flame sprites — allocated once with the scene; spawning a + // flame only mutates a slot. + function _buildFlamePool() { + _flamePool.length = 0; + _flameIdx = 0; + for (let i = 0; i < FLAME_POOL_SIZE; i++) { + const mat = new T.SpriteMaterial({ + map: _flameTexture(0), + transparent: true, + depthWrite: false, + blending: T.AdditiveBlending, + }); + const sprite = new T.Sprite(mat); + sprite.visible = false; + _flamesGroup.add(sprite); + _flamePool.push({ sprite, mat, start: -1, baseY: 0 }); + } + } + + function _spawnFlame(midi, wallNow) { + if (!_layoutInfo || !_flamePool.length) return; + const entry = _layoutInfo.layout.get(midi); + if (!entry) return; + const slot = _flamePool[_flameIdx]; + _flameIdx = (_flameIdx + 1) % _flamePool.length; + slot.mat.map = _flameTexture(((midi % 12) + 12) % 12); + slot.start = wallNow; + slot.baseY = entry.black ? BLACK_H + WHITE_H * 0.6 : WHITE_H; + slot.sprite.position.x = keyX(entry, _layoutInfo.whiteCount); + slot.sprite.position.z = -WHITE_L / 2; // at the hit-line + slot.sprite.visible = true; + } + + function buildKeyboardAndHighway() { + _clearGroup(keyboardGroup); + _hitGlowMats.length = 0; + keyMeshes = new Map(); + _keyAnim.clear(); + _keyFlash.clear(); + const range = _notation.range; + const { layout, whiteCount } = keyLayout(range); + _layoutInfo = { layout, whiteCount }; + const totalW = whiteCount * WHITE_W; + const floorW = totalW + 8 * WHITE_W; + const hitZ = -WHITE_L / 2; + + // Highway floor receding to the vanishing point. + const floor = new T.Mesh( + new T.PlaneGeometry(floorW, HIGHWAY_LEN), + new T.MeshStandardMaterial({ color: 0x141422, roughness: 0.9 }), + ); + floor.rotation.x = -Math.PI / 2; + floor.position.set(0, -0.5 * K, -HIGHWAY_LEN / 2 + WHITE_L); + keyboardGroup.add(floor); + + // Active-range pop: dim the scroll lanes outside the active span + // with subtle dark overlays so the playable range reads bright. + let activeMinX = Infinity, activeMaxX = -Infinity; + for (const [midi, entry] of layout) { + if (midi < range.activeLow || midi > range.activeHigh) continue; + const w = entry.black ? BLACK_W : WHITE_W; + const x = keyX(entry, whiteCount); + if (x - w / 2 < activeMinX) activeMinX = x - w / 2; + if (x + w / 2 > activeMaxX) activeMaxX = x + w / 2; + } + if (activeMinX < activeMaxX) { + const laneLen = HIGHWAY_LEN - WHITE_L; + const dimMat = new T.MeshBasicMaterial({ + color: 0x000000, transparent: true, opacity: 0.42, depthWrite: false, + }); + const zones = [ + [-floorW / 2, activeMinX], + [activeMaxX, floorW / 2], + ]; + for (const [x0, x1] of zones) { + const zoneW = x1 - x0; + if (zoneW <= 0.01 * K) continue; + const dim = new T.Mesh(new T.PlaneGeometry(zoneW, laneLen), dimMat); + dim.rotation.x = -Math.PI / 2; + dim.position.set((x0 + x1) / 2, 0.1 * K, hitZ - laneLen / 2); + keyboardGroup.add(dim); + } + } + + // Lane guides: a faint colour strip running up the runway from each + // active key, in that key's pitch-class colour. A falling note shares + // its target key's colour, so the player can trace it straight down + // its lane to the right key even when it sits near the frame edge. + // + // The lanes sit at the NOTES' travel height (coplanar), not on the + // deck. The camera looks down the y–z plane, so a note elevated above + // an on-floor lane projects to a different screen column (parallax) — + // putting the lane at the note's y makes the note ride exactly in its + // lane, perfectly aligned with the lane and its key. + const guideLen = HIGHWAY_LEN - WHITE_L; + const laneY = WHITE_H + NOTE_H / 2 + 0.5 * K; // == white-note travel height + for (const [midi, entry] of layout) { + if (midi < range.activeLow || midi > range.activeHigh) continue; + if (entry.black) continue; // one strip per semitone-slot lands on whites + const gmat = new T.MeshBasicMaterial({ + color: noteColor(midi, 'rh'), transparent: true, + opacity: 0.16, depthWrite: false, + }); + const strip = new T.Mesh(new T.PlaneGeometry(WHITE_W * 0.84, guideLen), gmat); + strip.rotation.x = -Math.PI / 2; + strip.position.set(keyX(entry, whiteCount), laneY, hitZ - guideLen / 2); + keyboardGroup.add(strip); + // Thin brighter rails at the lane edges for crisp separation. + const railMat = new T.MeshBasicMaterial({ + color: 0x2a2a3e, transparent: true, opacity: 0.5, depthWrite: false, + }); + const rail = new T.Mesh(new T.PlaneGeometry(0.6 * K, guideLen), railMat); + rail.rotation.x = -Math.PI / 2; + rail.position.set(keyX(entry, whiteCount) - WHITE_W / 2, laneY + 0.05 * K, hitZ - guideLen / 2); + keyboardGroup.add(rail); + } + + // Keys (whites first so blacks overlay). Geometries are shared + // across keys; materials are per key (range tint / depress + + // wrong-flash feedback are per-key state). The geometry origin + // is translated to the key's BACK edge so the live MIDI key- + // depress animation can pivot there (rotation.x > 0 tips the + // front of the key down, like a real piano action). + const whiteGeo = _roundedBoxGeo(WHITE_W * 0.94, WHITE_H, WHITE_L, WHITE_W * 0.05, WHITE_H * 0.1); + whiteGeo.translate(0, 0, WHITE_L / 2); + const blackGeo = _roundedBoxGeo(BLACK_W, BLACK_H, BLACK_L, BLACK_W * 0.08, BLACK_H * 0.06); + blackGeo.translate(0, 0, BLACK_L / 2); + const whiteGlyphGeo = new T.PlaneGeometry(WHITE_W * 0.72, WHITE_W * 0.72); + const blackGlyphGeo = new T.PlaneGeometry(BLACK_W * 0.96, BLACK_W * 0.96); + for (const [midi, entry] of layout) { + const black = entry.black; + const inRange = midi >= range.activeLow && midi <= range.activeHigh; + const material = new T.MeshStandardMaterial({ + color: black ? 0x070708 : 0xe8e8ee, + // Pitch-class colour preset on emissive but OFF at rest — the key + // is neutral until a note approaches, when updateScene ramps the + // intensity up by proximity. + emissive: noteColor(midi, 'rh'), + emissiveIntensity: 0, + roughness: 0.55, + }); + const mesh = new T.Mesh(black ? blackGeo : whiteGeo, material); + // Positions place the geometry where the old centred boxes + // sat: the mesh origin is the back-edge centre. + mesh.position.set( + keyX(entry, whiteCount), + black ? BLACK_H / 2 + WHITE_H * 0.1 : WHITE_H / 2, + black ? (WHITE_L - BLACK_L) / 2 - WHITE_L / 2 - BLACK_L / 2 - 4 * K : -WHITE_L / 2, + ); + mesh.userData.midi = midi; + // Wrong-note flash restore state. + mesh.userData.origEmissive = material.emissive.getHex(); + mesh.userData.origEmissiveIntensity = material.emissiveIntensity; + keyboardGroup.add(mesh); + keyMeshes.set(midi, mesh); + + if (inRange) { + // RS+-style letter printed on the key top — dark text + // near the front edge of whites, light text on blacks. + // The glyph is a CHILD of the key mesh so it rides the + // depress animation (positions are key-local). + const glyph = new T.Mesh( + black ? blackGlyphGeo : whiteGlyphGeo, + new T.MeshBasicMaterial({ + map: _glyphTexture(noteLetter(midi), !black), + transparent: true, + depthWrite: false, + }), + ); + glyph.rotation.x = -Math.PI / 2; + glyph.position.set( + 0, + black ? BLACK_H / 2 + 0.15 * K : WHITE_H / 2 + 0.15 * K, + black ? BLACK_L / 2 : WHITE_L - WHITE_W * 0.55, + ); + mesh.add(glyph); + } + } + + // Glowing hit-line: a bright core bar plus two additive gradient + // planes (a flat wash on the deck and an upright curtain) pulsed + // gently in draw(). No postprocessing — just cheap blending. + const lineW = totalW + 4 * WHITE_W; + hitLine = new T.Mesh( + new T.BoxGeometry(lineW, 1.2 * K, 1.2 * K), + new T.MeshBasicMaterial({ color: 0xb8fff0 }), + ); + hitLine.position.set(0, WHITE_H + 0.5 * K, hitZ); + keyboardGroup.add(hitLine); + + const flatGlowMat = new T.MeshBasicMaterial({ + map: _glowTexture(), transparent: true, opacity: 0.85, + blending: T.AdditiveBlending, depthWrite: false, + }); + const flatGlow = new T.Mesh(new T.PlaneGeometry(lineW, 16 * K), flatGlowMat); + flatGlow.rotation.x = -Math.PI / 2; + flatGlow.position.set(0, 0.2 * K, hitZ - 5 * K); + keyboardGroup.add(flatGlow); + + const upGlowMat = flatGlowMat.clone(); + const upGlow = new T.Mesh(new T.PlaneGeometry(lineW, 7 * K), upGlowMat); + upGlow.position.set(0, WHITE_H + 1.5 * K, hitZ + 0.1 * K); + keyboardGroup.add(upGlow); + _hitGlowMats.push(flatGlowMat, upGlowMat); + } + + function buildNoteMeshes() { + _clearGroup(notesGroup); + _clearNoteCaches(); + noteMeshes = []; + const range = _notation.range; + const { layout, whiteCount } = keyLayout(range); + for (const note of _notation.notes) { + const entry = layout.get(note.midi); + if (!entry) continue; + const len = Math.max(4 * K, note.durSec * TS); + const w = (entry.black ? BLACK_W : WHITE_W * 0.94) * 0.9; + // Clone per note so each can glow independently while being consumed. + const mesh = new T.Mesh(_noteGeometry(w, len), _noteMaterial(note.midi, note.hand).clone()); + mesh.position.x = keyX(entry, whiteCount); + mesh.position.y = (entry.black ? BLACK_H + WHITE_H : WHITE_H) + NOTE_H / 2 + 0.5 * K; + mesh.visible = false; + notesGroup.add(mesh); + // Note-name label: a camera-facing sprite (readable at this low camera + // angle, unlike a flat top decal). Lives in notesGroup as a sibling — + // NOT a child of the note mesh — so the consumption z-clip never + // squashes the letter. Positioned per frame in updateScene. + const label = new T.Sprite(new T.SpriteMaterial({ + map: _glyphTexture(noteLetter(note.midi), false), + transparent: true, depthWrite: false, + })); + label.scale.set(6.5 * K, 6.5 * K, 1); + label.visible = false; + notesGroup.add(label); + noteMeshes.push({ mesh, note, len, label }); + } + } + + // Floating bar numbers: one camera-facing sprite per measure marker, + // parked on the left shoulder of the highway and scrolled with the + // notes in draw(). + function buildMarkerSprites() { + _clearGroup(markersGroup); + _clearBarTextures(); + markerSprites = []; + const { whiteCount } = keyLayout(_notation.range); + const totalW = whiteCount * WHITE_W; + for (const marker of _notation.markers) { + const mat = new T.SpriteMaterial({ + map: _barNumberTexture(marker.idx), + transparent: true, + depthWrite: false, + opacity: 0.9, + }); + const sprite = new T.Sprite(mat); + sprite.scale.set(16 * K, 8 * K, 1); + sprite.position.set(-totalW / 2 - 2.2 * WHITE_W, 3 * K, 0); + sprite.visible = false; + markersGroup.add(sprite); + markerSprites.push({ sprite, t: marker.t }); + } + } + + /* ── MIDI event handlers (called by module _midiOnMessage) ─────── + * + * Receive the RAW midi note from the device; transpose is applied + * here and the result stored under the raw note so note-off finds + * it even if the transpose changed in between (piano invariant). + */ + + function _handleNoteOn(rawMidi, velocity, tOverride) { + if (rawMidi < 0 || rawMidi > 127) return; + const played = rawMidi + _cfg.transpose; + if (played < 0 || played > 127) return; + _rawToPlayed.set(rawMidi, played); + // Retrigger while the pedal is down: the key is physically held + // again, so it's no longer merely pedal-sustained. Without this, + // pedal-up would un-depress (and stop tracking) a key the player + // is still holding. _heldNotes is the visual-depress set (physical + // OR pedal-sustained); _sustainedNotes is the pedal-only subset. + _sustainedNotes.delete(played); + _heldNotes.add(played); + // Key depress starts immediately on the LIVE input path — + // feedback must not wait for hit judgment. + if (keyMeshes.has(played)) _keyAnim.set(played, true); + _synthNoteOn(played, velocity); // hear the played note + _checkHit(played, Number.isFinite(tOverride) ? tOverride : _latestTime); + } + + function _handleNoteOff(rawMidi) { + if (rawMidi < 0 || rawMidi > 127) return; + const played = _rawToPlayed.get(rawMidi); + if (played == null) return; // stray note-off (cleared state) + _rawToPlayed.delete(rawMidi); + if (_sustainOn) { + _sustainedNotes.add(played); + return; // pedal held → keep sounding (synth released on pedal-up) + } + _heldNotes.delete(played); + _synthNoteOff(played); + } + + function _handleSustain(down) { + if (down) { + _sustainOn = true; + } else { + _sustainOn = false; + for (const midi of _sustainedNotes) { _heldNotes.delete(midi); _synthNoteOff(midi); } + _sustainedNotes.clear(); + } + } + + function _releaseAllHeld() { + _heldNotes.clear(); + _sustainedNotes.clear(); + _rawToPlayed.clear(); + _sustainOn = false; + _synthAllOff(); + } + + /* ── Hit detection / scoring (piano _checkHit port) ───────────── */ + + function _checkHit(playedMidi, t) { + if (!_notation || !_notation.notes.length) return; + const key = judgeHit(_notation.notes, playedMidi, t, _hitNoteKeys, HIT_TOLERANCE_S); + const wall = performance.now(); + if (key) { + _hitNoteKeys.add(key); + _hits++; + _streak++; + if (_streak > _bestStreak) _bestStreak = _streak; + _keyFlash.delete(playedMidi); // a hit cancels a lingering red + _spawnFlame(playedMidi, wall); + _ndReport(true, playedMidi, _ndBindingId); + } else { + _misses++; + _streak = 0; + if (keyMeshes.has(playedMidi)) _keyFlash.set(playedMidi, wall); + _ndReport(false, playedMidi, _ndBindingId); + } + } + + function _resetScoring() { + _hits = 0; _misses = 0; _streak = 0; _bestStreak = 0; + _hitNoteKeys.clear(); + _missedNoteKeys.clear(); + _sweepCursor.idx = 0; + _missFloor = null; + _keyFlash.clear(); + // Fresh chart, fresh timing cursor — a note-on landing between + // the new chart build and its first draw() must be judged at + // the new run's start, not against the previous song's time. + _latestTime = 0; + } + + // Swept-miss callback — kept as a named function so the per-frame + // sweep passes a stable reference (no closure allocation in draw()). + function _onSweptMiss(note) { + _ndReport(false, note.midi, _ndBindingId); + } + + /* ── End-of-run stats POST (exactly once per run) ──────────────── */ + + async function _finalizeRun() { + if (_recordedThisRun) return; + // When the note-detection domain is present, the consumer (notedetect + // → core stats-recorder) owns stats + progression, posting/awarding + // from our reported verdicts. Defer to it so we don't double-count + // the play or double-award feedback points. + if (window.slopsmith && window.slopsmith.noteDetection && window.slopsmith.noteDetection.version === 1) { + _recordedThisRun = true; + return; + } + if (!_runMeta || !_runMeta.filename) return; + if (_hits + _misses <= 0) return; // nothing was scored this run + _recordedThisRun = true; + const accuracy = accuracyOf(_hits, _misses); + const body = { + filename: _runMeta.filename, + arrangement: _runMeta.arrangement, + score: scoreOf(_hits, _misses), + accuracy, + hits: _hits, + misses: _misses, + bestStreak: _bestStreak, + }; + let resp = null; + try { + const r = await fetch('/api/stats', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + resp = await r.json().catch(() => null); + } catch (_) { /* offline / endpoint absent — non-fatal */ } + // Fan the outcome out to the progression core (guarded — older + // servers have neither the field nor the host). + try { window.v3Progression?.notify(resp ? resp.progression : null); } catch (_) {} + } + + /* ── Live feedback animation (no per-frame allocations) ────────── */ + + function _animateFeedback(wallNow) { + const dt = Math.min(Math.max(wallNow - _lastWallMs, 0), 100); + _lastWallMs = wallNow; + // Key depress: exponential spring toward the held/released + // target angle, pivoting at the back edge (geometry origin). + const k = 1 - Math.exp(-dt / KEY_PRESS_TAU_MS); + for (const midi of _keyAnim.keys()) { + const mesh = keyMeshes.get(midi); + if (!mesh) { _keyAnim.delete(midi); continue; } + const target = _heldNotes.has(midi) ? KEY_PRESS_ANGLE : 0; + const next = mesh.rotation.x + (target - mesh.rotation.x) * k; + mesh.rotation.x = next; + if (target === 0 && Math.abs(next) < 0.001) { + mesh.rotation.x = 0; + _keyAnim.delete(midi); + } + } + // Wrong-note red flash, fading back to the key's own emissive. + for (const [midi, start] of _keyFlash) { + const mesh = keyMeshes.get(midi); + if (!mesh) { _keyFlash.delete(midi); continue; } + const p = (wallNow - start) / WRONG_FLASH_MS; + if (p >= 1) { + mesh.material.emissive.setHex(mesh.userData.origEmissive); + mesh.material.emissiveIntensity = mesh.userData.origEmissiveIntensity; + _keyFlash.delete(midi); + } else { + mesh.material.emissive.setHex(0xff2020); + mesh.material.emissiveIntensity = 0.9 * (1 - p); + } + } + // Flame flares: rise, widen and fade over FLAME_MS. + for (let i = 0; i < _flamePool.length; i++) { + const slot = _flamePool[i]; + if (slot.start < 0) continue; + const age = (wallNow - slot.start) / FLAME_MS; + if (age >= 1) { + slot.start = -1; + slot.sprite.visible = false; + continue; + } + const h = 14 * K * (0.6 + 0.9 * age); + slot.sprite.scale.set(8 * K * (1 + 0.3 * age), h, 1); + slot.sprite.position.y = slot.baseY + h / 2; + slot.mat.opacity = 1 - age; + } + } + + function updateScene(now) { + const hitZ = -WHITE_L / 2; + + // Pan the camera x to follow the active hand: a hit-line-weighted + // centroid of the notes around `now`. Keeps a ~2-octave window + // framed and scrolls with the melody, RS+-style, instead of + // statically framing the whole range. + let minX = Infinity, maxX = -Infinity; + for (const { mesh, note } of noteMeshes) { + const dt = note.t - now; + if (dt < -0.4 || dt > CAM_ZOOM_AHEAD) continue; + const x = mesh.position.x; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + } + if (maxX >= minX) { + // Centre on the span midpoint so the lowest and highest target + // keys are equidistant and both stay in view; zoom to fit the span. + _camTargetX = (minX + maxX) / 2; + const span = (maxX - minX) + CAM_ZOOM_PAD; + const tz = span / (CAM_ZOOM_BASE_KEYS * WHITE_W); + _camTargetZoom = Math.max(CAM_ZOOM_MIN, Math.min(CAM_ZOOM_MAX, tz)); + } + _camX += (_camTargetX - _camX) * CAM_PAN_LERP; + _camZoom += (_camTargetZoom - _camZoom) * CAM_ZOOM_LERP; + cam.position.set(_camX, CAM_Y * _camZoom, CAM_Z * _camZoom); + cam.lookAt(_camX, LOOK_Y * _camZoom, LOOK_Z * _camZoom); + + for (const km of keyMeshes.values()) km.userData.glow = 0; + for (const { mesh, note, len, label } of noteMeshes) { + const dt = note.t - now; + // Key approach-glow: the nearest upcoming note lights its key, + // stronger the closer it is to the hit-line. + if (dt >= -0.05 && dt <= KEY_GLOW_AHEAD) { + const km = keyMeshes.get(note.midi); + if (km) { + const p = 1 - dt / KEY_GLOW_AHEAD; + if (p > km.userData.glow) km.userData.glow = p; + } + } + // Note front edge arrives at the hit-line at note.t. + const z = scrollZ(note.t, now, hitZ, TS) - len / 2; + const frontZ = z + len / 2; + const backZ = z - len / 2; + const past = frontZ - hitZ; + + // Note-name label: full while approaching; once the note starts being + // consumed, fade out over LABEL_FADE_DIST. Driven by `past` (not the + // mesh state) so it lingers and fades even after the note is eaten. + if (label) { + if (frontZ < -HIGHWAY_LEN) { + label.visible = false; + } else if (past <= 0) { + label.visible = true; + label.material.opacity = 1; + label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, z); + } else { + const op = 1 - past / LABEL_FADE_DIST; + if (op <= 0) { + label.visible = false; + } else { + label.visible = true; + label.material.opacity = op; + const rem = Math.max(0, len - past); + label.position.set(mesh.position.x, mesh.position.y + NOTE_H / 2 + 1.6 * K, hitZ - rem / 2); + } + } + } + + if (frontZ < -HIGHWAY_LEN || backZ > hitZ + 20 * K) { + mesh.visible = false; + continue; + } + mesh.visible = true; + // True sustain consumption: once the leading edge reaches the + // hit-line, pin the front there and shorten the note from the front + // as it's "eaten", while the tail keeps feeding down the runway. The + // gem geometry has length `len`, so scale Z by remaining/len and + // recentre on the still-visible [backZ, hitZ] segment. + if (past <= 0) { + if (mesh.scale.z !== 1) mesh.scale.set(1, 1, 1); + mesh.position.z = z; + if (mesh.material.emissiveIntensity !== NOTE_EMISSIVE_BASE) { + mesh.material.emissiveIntensity = NOTE_EMISSIVE_BASE; + } + } else { + const remaining = len - past; // = hitZ - backZ, length still up the runway + if (remaining <= 0.02 * K) { mesh.visible = false; continue; } + mesh.scale.set(1, 1, remaining / len); + mesh.position.z = hitZ - remaining / 2; + // Glow as it's eaten — intensifies the more of the note is consumed. + const consumed = Math.min(1, past / len); + mesh.material.emissiveIntensity = + NOTE_EMISSIVE_BASE + (CONSUME_GLOW - NOTE_EMISSIVE_BASE) * (0.45 + 0.55 * consumed); + } + } + // Apply the approach-glow (a wrong-note red flash owns emissive meanwhile). + for (const [midi, km] of keyMeshes) { + if (_keyFlash.has(midi)) continue; + const g = km.userData.glow || 0; + km.material.emissiveIntensity = g * g * KEY_GLOW_STRENGTH; // ease → pops near the hit-line + } + for (const entry of markerSprites) { + const z = scrollZ(entry.t, now, hitZ, TS); + if (z < -HIGHWAY_LEN || z > hitZ + 6 * K) { + entry.sprite.visible = false; + continue; + } + entry.sprite.visible = true; + entry.sprite.position.z = z; + } + // Gentle hit-line pulse — two material opacity writes, no + // allocations. + const pulse = 0.72 + 0.18 * Math.sin(now * 5.0); + for (let i = 0; i < _hitGlowMats.length; i++) _hitGlowMats[i].opacity = pulse; + + // Missed-note sweep — only while a MIDI device is connected + // (without one, every note would count as a miss and corrupt + // accuracy), and never retroactively across a mid-song connect. + // Only the focused instance scores: note-ons route solely to + // _activeInstance, so sweeping misses anywhere else would accrue + // misses with no possible hits and corrupt that run's stats + // (splitscreen / overlapping renderer lifetimes). + // Gate on _midiHandle (the live wired session), NOT _midiInput: the + // latter is set as soon as a device is picked, but the async mi.open() + // may still be pending (slow / permission prompt), during which no + // events can arrive — sweeping then would bank false misses. _midiHandle + // is truthy only after a handle is opened and wired. + if (_midiHandle && _notation && _activeInstance === instance) { + if (_midiJustConnected) { + _missFloor = now; + _midiJustConnected = false; + } + const swept = sweepMissed( + _notation.notes, now, _hitNoteKeys, _missedNoteKeys, + HIT_TOLERANCE_S, _missFloor, _onSweptMiss, _sweepCursor, + ); + if (swept) { _misses += swept; _streak = 0; } + } + _latestTime = now; + } + + async function loadNotationForCurrentSong() { + const song = window.slopsmith && window.slopsmith.currentSong; + if (!song || !song.filename) return; + const seq = ++_loadSeq; + try { + const { measures } = await fetchNotation(song.filename, song.arrangementIndex != null ? song.arrangementIndex : -1); + if (seq !== _loadSeq || !_isReady) return; // superseded / torn down + const notes = flattenNotation(measures); + _notation = { + notes, + range: keyRange(notes), + markers: measureMarkers(measures), + }; + buildKeyboardAndHighway(); + buildNoteMeshes(); + buildMarkerSprites(); + // Finalize the OUTGOING run before we clobber its scoring + // state. The host may emit song:loaded for the next song + // before the prior run's song:ended/stop (or never emit one + // for a seamless switch); without this the previous run would + // post zero times, or worse, post the new song's metadata. + // _finalizeRun is a no-op when nothing was scored or it + // already posted, so a normal end→load sequence is unaffected. + // Not awaited: _finalizeRun latches _recordedThisRun and + // snapshots the POST body synchronously BEFORE its `await + // fetch`, so the reset below can't corrupt it — and a slow or + // hung /api/stats must never block the next chart's load. + void _finalizeRun(); + // Fresh run: scoring + once-per-run stats latch reset here. + _resetScoring(); + _recordedThisRun = false; + _runMeta = { + filename: song.filename, + arrangement: Number.isFinite(Number(song.arrangementIndex)) ? Number(song.arrangementIndex) : 0, + }; + _ndOpenBindingForChart(_notation.range, seq); + _emitDomain('renderer-ready', { providerId: 'keys_highway_3d' }); + } catch (e) { + console.error('[Keys-Hwy3D] notation load failed:', e); + _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'notation load failed' }); + } + } + + // Per-song note-detection binding: close the previous one, register + // the provider (idempotent) and open a binding scoped to this + // chart's keys range. All guarded — degrades silently when the + // spec-009 host is absent. `seq` is the chart-load sequence: a + // rapid song switch can land a stale open-binding response after + // the next chart is active, which would misattribute hit/miss + // events and leak the live binding — so the write-back is gated on + // the same supersession check the notation fetch uses, and a + // superseded binding is closed instead of stored. + async function _ndOpenBindingForChart(range, seq) { + if (_ndBindingId) { + _capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId }, + 'Song changed — close the previous keys binding'); + _ndBindingId = null; + } + await _ndEnsureProvider(); + const p = await _capCommand('note-detection', 'open-binding', { + providerId: ND_PROVIDER_ID, + context: { arrangement: 'keys', midiLow: range.activeLow, midiHigh: range.activeHigh }, + }, 'Open a keys verify binding for the loaded chart'); + const bindingId = p && p.bindingId; + if (!bindingId) return; + if (seq !== _loadSeq || !_isReady) { + _capCommand('note-detection', 'close-binding', { bindingId }, + 'Superseded by a newer chart load'); + return; + } + _ndBindingId = bindingId; + } + + function applySize(w, h) { + if (!ren || !cam || !w || !h) return; + ren.setSize(w, h, false); + cam.aspect = w / h; + cam.updateProjectionMatrix(); + } + + function teardown() { + const sm = window.slopsmith; + if (sm && typeof sm.off === 'function') { + if (_songHandler) sm.off('song:loaded', _songHandler); + if (_endHandler) sm.off('song:ended', _endHandler); + if (_stopHandler) sm.off('song:stop', _stopHandler); + } + _songHandler = _endHandler = _stopHandler = null; + if (_ndBindingId) { + _capCommand('note-detection', 'close-binding', { bindingId: _ndBindingId }, + 'Renderer torn down'); + _ndBindingId = null; + } + _clearGroup(notesGroup); + _clearGroup(markersGroup); + _clearGroup(keyboardGroup); + _clearGroup(_flamesGroup); + _flamePool.length = 0; + _clearNoteCaches(); + _clearBarTextures(); + _clearFlameTextures(); + if (_glowTex) { _glowTex.dispose(); _glowTex = null; } + _hitGlowMats.length = 0; + if (ren) ren.dispose(); + ren = scene = cam = notesGroup = keyboardGroup = markersGroup = hitLine = null; + _flamesGroup = null; + noteMeshes = []; + markerSprites = []; + keyMeshes = new Map(); + _layoutInfo = null; + _keyAnim.clear(); + _keyFlash.clear(); + _releaseAllHeld(); + _notation = null; + _isReady = false; + } + + const instance = { + contextType: 'webgl2', + + init(canvas, _bundle) { + if (_isReady) teardown(); + highwayCanvas = canvas; + loadThree().then(() => { + if (!highwayCanvas) return; // destroyed before load resolved + try { + ren = new T.WebGLRenderer({ canvas: highwayCanvas, antialias: true, alpha: false }); + ren.setClearColor(FOG_COLOR, 1); + } catch (e) { + console.error('[Keys-Hwy3D] WebGL2 init failed:', e); + _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'webgl2 init failed' }); + return; + } + buildScene(); + applySize(highwayCanvas.clientWidth, highwayCanvas.clientHeight); + _isReady = true; + loadNotationForCurrentSong(); + if (window.slopsmith && typeof window.slopsmith.on === 'function') { + _songHandler = () => loadNotationForCurrentSong(); + window.slopsmith.on('song:loaded', _songHandler); + // End-of-run stats: finalize on natural end AND on + // an early stop (player closed the song) — the + // once-per-run latch makes the pair idempotent. + _endHandler = () => { _finalizeRun(); }; + _stopHandler = () => { _finalizeRun(); }; + window.slopsmith.on('song:ended', _endHandler); + window.slopsmith.on('song:stop', _stopHandler); + } + // MIDI lifecycle: this instance takes focus; _midiInit + // is idempotent (no repeat permission prompt) and + // _midiActive gates the async access resolution so a + // destroy() before it lands can't wire a dead handler. + _instances.add(instance); + _activeInstance = instance; + _midiInit(); + _midiResume(); + }).catch(() => { + _emitDomain('renderer-failed', { providerId: 'keys_highway_3d', reason: 'three load failed' }); + }); + }, + + draw(bundle) { + if (!_isReady || !ren || !scene || !cam) return; + const now = (bundle && typeof bundle.currentTime === 'number') ? bundle.currentTime : 0; + if (_notation) updateScene(now); + _animateFeedback(performance.now()); + ren.render(scene, cam); + }, + + resize(w, h) { + if (!_isReady) return; + applySize(w, h); + }, + + destroy() { + _instances.delete(instance); + // If the focused instance is going away but others remain + // (splitscreen teardown of one panel), promote a survivor so + // _midiOnMessage keeps routing instead of dropping every + // event on its `if (!_activeInstance) return` guard. + if (_activeInstance === instance) { + _activeInstance = null; + for (const inst of _instances) { _activeInstance = inst; break; } + } + if (_instances.size === 0) _midiReleaseSession(); + teardown(); + highwayCanvas = null; + }, + + // ── Module MIDI router surface (focused-instance dispatch) ── + _handleNoteOn, + _handleNoteOff, + _handleSustain, + _releaseAllHeld, + + // ── Headless test hooks (window.__keysHwTest) ─────────────── + _injectNoteOn(midi, when) { + // Behaves like a device note-on of `midi` at song-time + // `when` (defaults to the last drawn bundle time). + _handleNoteOn(midi, 100, when); + }, + _getScore() { + return { + hits: _hits, + misses: _misses, + streak: _streak, + bestStreak: _bestStreak, + accuracy: accuracyOf(_hits, _misses), + score: scoreOf(_hits, _misses), + }; + }, + }; + return instance; + } + + /* ====================================================================== + * Register + * ====================================================================== */ + + window.slopsmithViz_keys_highway_3d = createFactory; + // slopsmith→feedBack rename: host viz picker looks up `window.feedBackViz_`. + window.feedBackViz_keys_highway_3d = window.slopsmithViz_keys_highway_3d; + // Static contextType so core can make canvas-swap decisions before + // constructing a renderer instance (and gate Auto on WebGL2). + window.slopsmithViz_keys_highway_3d.contextType = 'webgl2'; + // Auto-select on arrangements that carry notation (sloppak-spec §5.3). + // Predicates receive the raw song_info wire frame — snake_case field. + // Directory name `keys_highway_3d` sorts before `piano` and `staffview` + // so Auto prefers the 3D highway when all three match. + window.slopsmithViz_keys_highway_3d.matchesArrangement = function (songInfo) { + return !!(songInfo && songInfo.has_notation); + }; + // Pure data-layer + scoring hooks for headless tests. + window.slopsmithViz_keys_highway_3d.__test = { + beatDurSec, + flattenNotation, + keyRange, + measureMarkers, + noteLetter, + scrollZ, + noteKey, + accuracyOf, + scoreOf, + judgeHit, + sweepMissed, + }; + + // Headless verification hook: lets Playwright drive synthetic note-ons + // through the full hit-detection + feedback path and read the live + // score without a physical MIDI device. + window.__keysHwTest = { + injectNoteOn(midi, when) { + if (_activeInstance && typeof _activeInstance._injectNoteOn === 'function') { + _activeInstance._injectNoteOn(midi, when); + } + }, + getScore() { + return (_activeInstance && typeof _activeInstance._getScore === 'function') + ? _activeInstance._getScore() : null; + }, + }; +})(); diff --git a/plugins/keys_highway_3d/tests/data_layer.test.js b/plugins/keys_highway_3d/tests/data_layer.test.js new file mode 100644 index 0000000..1609a8d --- /dev/null +++ b/plugins/keys_highway_3d/tests/data_layer.test.js @@ -0,0 +1,190 @@ +// Pure data-layer tests: load screen.js in a bare vm window and exercise the +// __test exports (no DOM, no WebGL, no network). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return window.slopsmithViz_keys_highway_3d.__test; +} + +test('beatDurSec: base, dotted, double-dotted, tuplet', () => { + const { beatDurSec } = load(); + // 120 BPM → quarter = 0.5s + assert.equal(beatDurSec({ dur: 4 }, 120), 0.5); + assert.equal(beatDurSec({ dur: 2 }, 120), 1.0); + assert.equal(beatDurSec({ dur: 8 }, 120), 0.25); + // dotted quarter = 0.75s; double-dotted = 0.875s + assert.equal(beatDurSec({ dur: 4, dot: 1 }, 120), 0.75); + assert.equal(beatDurSec({ dur: 4, dot: 2 }, 120), 0.875); + // triplet eighth: 0.25 * 2/3 + assert.ok(Math.abs(beatDurSec({ dur: 8, tu: [3, 2] }, 120) - 0.25 * 2 / 3) < 1e-9); + // invalid → null + assert.equal(beatDurSec({ dur: 0 }, 120), null); + assert.equal(beatDurSec({ dur: 4 }, null), null); +}); + +function measure(idx, t, opts = {}) { + return { idx, t, ...opts }; +} + +test('flattenNotation: basic two-hand flatten, sorted, durSec from tempo', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + { + idx: 1, t: 0, tempo: 120, + staves: { + rh: { voices: [{ v: 1, beats: [ + { t: 0.5, dur: 4, notes: [{ midi: 64 }] }, + { t: 1.0, dur: 8, notes: [{ midi: 67 }] }, + ] }] }, + lh: { voices: [{ v: 1, beats: [ + { t: 0.0, dur: 2, notes: [{ midi: 48 }] }, + ] }] }, + }, + }, + ]); + assert.equal(notes.length, 3); + assert.deepEqual(JSON.parse(JSON.stringify(notes.map(n => n.midi))), [48, 64, 67]); // time-sorted + assert.equal(notes[0].hand, 'lh'); + assert.equal(notes[0].durSec, 1.0); // half at 120 + assert.equal(notes[1].durSec, 0.5); // quarter + assert.equal(notes[2].durSec, 0.25); // eighth + assert.equal(notes[1].measureIdx, 1); +}); + +test('flattenNotation: tempo state carries across measures and changes apply', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + measure(1, 0, { tempo: 120, staves: { rh: { voices: [{ v: 1, beats: [{ t: 0, dur: 4, notes: [{ midi: 60 }] }] }] } } }), + measure(2, 2, { staves: { rh: { voices: [{ v: 1, beats: [{ t: 2, dur: 4, notes: [{ midi: 62 }] }] }] } } }), + measure(3, 4, { tempo: 60, staves: { rh: { voices: [{ v: 1, beats: [{ t: 4, dur: 4, notes: [{ midi: 64 }] }] }] } } }), + ]); + assert.equal(notes[0].durSec, 0.5); // 120 BPM + assert.equal(notes[1].durSec, 0.5); // tempo carried + assert.equal(notes[2].durSec, 1.0); // 60 BPM +}); + +test('flattenNotation: tied notes extend instead of emitting a new block', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + { + idx: 1, t: 0, tempo: 120, + staves: { rh: { voices: [{ v: 1, beats: [ + { t: 0.0, dur: 2, notes: [{ midi: 60 }] }, + { t: 1.0, dur: 2, notes: [{ midi: 60, tied: true }] }, + ] }] } }, + }, + ]); + assert.equal(notes.length, 1); + assert.equal(notes[0].durSec, 2.0); // half + tied half +}); + +test('flattenNotation: no tempo anywhere falls back to next-onset gap', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + { + idx: 1, t: 0, + staves: { rh: { voices: [{ v: 1, beats: [ + { t: 0.0, dur: 4, notes: [{ midi: 60 }] }, + { t: 0.8, dur: 4, notes: [{ midi: 62 }] }, + ] }] } }, + }, + ]); + assert.ok(Math.abs(notes[0].durSec - 0.8) < 1e-9); + assert.equal(notes[1].durSec, 2.0); // final-beat fallback +}); + +test('flattenNotation: overlap clamp against next same-hand same-midi onset', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + { + idx: 1, t: 0, tempo: 30, // whole note = 8s — way past the next onset + staves: { rh: { voices: [{ v: 1, beats: [ + { t: 0.0, dur: 1, notes: [{ midi: 60 }] }, + { t: 1.0, dur: 1, notes: [{ midi: 60 }] }, + ] }] } }, + }, + ]); + assert.equal(notes[0].durSec, 1.0); // clamped to next onset +}); + +test('flattenNotation: rests, malformed beats, and out-of-range midi are skipped', () => { + const { flattenNotation } = load(); + const notes = flattenNotation([ + { + idx: 1, t: 0, tempo: 120, + staves: { rh: { voices: [{ v: 1, beats: [ + { t: 0.0, dur: 4, rest: true }, + { t: 0.5, dur: 4, notes: [{ midi: 200 }] }, + null, + { t: 1.0, dur: 4, notes: [{ midi: 64 }] }, + ] }] } }, + }, + ]); + assert.equal(notes.length, 1); + assert.equal(notes[0].midi, 64); +}); + +test('keyRange pads and clamps to the 88-key piano, keeps active span explicit', () => { + const { keyRange } = load(); + assert.deepEqual( + JSON.parse(JSON.stringify(keyRange([{ midi: 60 }, { midi: 72 }]))), + { low: 58, high: 74, activeLow: 60, activeHigh: 72 }, + ); + // At the clamp edges the active span still reflects the chart extremes + // (not low+pad — that would mark A0/C8 inactive when actually played). + assert.deepEqual( + JSON.parse(JSON.stringify(keyRange([{ midi: 21 }, { midi: 108 }]))), + { low: 21, high: 108, activeLow: 21, activeHigh: 108 }, + ); + const empty = keyRange([]); + assert.ok(empty.low < 60 && empty.high > 60); + assert.ok(empty.activeLow > empty.activeHigh, 'empty chart has an empty active span'); +}); + +test('noteLetter maps midi to pitch-class letters', () => { + const { noteLetter } = load(); + assert.equal(noteLetter(60), 'C'); + assert.equal(noteLetter(61), 'C#'); + assert.equal(noteLetter(69), 'A'); + assert.equal(noteLetter(71), 'B'); + assert.equal(noteLetter(72), 'C'); // octave wraps + assert.equal(noteLetter(21), 'A'); // A0 +}); + +test('scrollZ: events sit at hitZ exactly at their time and approach from -Z', () => { + const { scrollZ } = load(); + const hitZ = -0.5, speed = 2.0; + // At now === eventT the event is exactly on the hit-line. + assert.equal(scrollZ(10, 10, hitZ, speed), hitZ); + // 1s before its time it is `speed` units further away (towards -Z). + assert.equal(scrollZ(10, 9, hitZ, speed), hitZ - speed); + // After its time it has moved past the hit-line (towards +Z). + assert.equal(scrollZ(10, 11, hitZ, speed), hitZ + speed); + // Marker and note-front-edge maths agree by construction: a note of + // length L positioned at scrollZ(t) - L/2 has its front edge at + // scrollZ(t). + const len = 0.8; + assert.equal(scrollZ(10, 10, hitZ, speed) - len / 2 + len / 2, hitZ); +}); + +test('measureMarkers extracts idx/t pairs', () => { + const { measureMarkers } = load(); + assert.deepEqual( + JSON.parse(JSON.stringify(measureMarkers([{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }, { bogus: true }]))), + [{ idx: 1, t: 0 }, { idx: 2, t: 2.5 }], + ); +}); diff --git a/plugins/keys_highway_3d/tests/scoring.test.js b/plugins/keys_highway_3d/tests/scoring.test.js new file mode 100644 index 0000000..feaf3ca --- /dev/null +++ b/plugins/keys_highway_3d/tests/scoring.test.js @@ -0,0 +1,154 @@ +// Pure MIDI-scoring tests: load screen.js in a bare vm window and exercise +// the __test exports (no DOM, no WebGL, no MIDI device, no network). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function load() { + const window = { + console, + location: { protocol: 'http:', host: 'localhost' }, + slopsmith: {}, + }; + window.window = window; + window.globalThis = window; + const context = vm.createContext(window); + const src = fs.readFileSync(path.join(__dirname, '..', 'screen.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'screen.js' }); + return window.slopsmithViz_keys_highway_3d.__test; +} + +const TOL = 0.10; + +test('accuracyOf/scoreOf mirror the notedetect stats formula', () => { + const { accuracyOf, scoreOf } = load(); + // accuracy = hits / max(1, hits + misses) + assert.equal(accuracyOf(0, 0), 0); + assert.equal(accuracyOf(10, 0), 1); + assert.equal(accuracyOf(3, 1), 0.75); + // score = round(hits * 100 * accuracy) + assert.equal(scoreOf(0, 0), 0); + assert.equal(scoreOf(10, 0), 1000); + assert.equal(scoreOf(3, 1), Math.round(3 * 100 * 0.75)); + // Monotonic in accuracy at fixed hits. + assert.ok(scoreOf(5, 0) > scoreOf(5, 5)); +}); + +test('judgeHit: exact note inside ±0.10 s window hits; outside misses', () => { + const { judgeHit } = load(); + const notes = [ + { midi: 60, t: 1.0 }, + { midi: 64, t: 1.0 }, + { midi: 62, t: 2.0 }, + ]; + const hitKeys = new Set(); + // On time. + assert.equal(judgeHit(notes, 60, 1.0, hitKeys, TOL), '1.000|60'); + // Near the edge of the window (the exact ±0.10 boundary is float- + // representation dependent, same as the piano plugin). + assert.equal(judgeHit(notes, 62, 2.099, hitKeys, TOL), '2.000|62'); + assert.equal(judgeHit(notes, 64, 0.901, hitKeys, TOL), '1.000|64'); + // Just outside the window. + assert.equal(judgeHit(notes, 60, 1.11, hitKeys, TOL), null); + // Wrong note — nothing at that midi anywhere near. + assert.equal(judgeHit(notes, 65, 1.0, hitKeys, TOL), null); +}); + +test('judgeHit: dedupes by t|midi — a chart note can only be hit once', () => { + const { judgeHit } = load(); + const notes = [{ midi: 60, t: 1.0 }, { midi: 60, t: 1.15 }]; + const hitKeys = new Set(); + const first = judgeHit(notes, 60, 1.02, hitKeys, TOL); + assert.equal(first, '1.000|60'); + hitKeys.add(first); + // Second strike near the same time falls through to the NEXT un-hit + // chart note at the same midi (double-stop repeats). + const second = judgeHit(notes, 60, 1.06, hitKeys, TOL); + assert.equal(second, '1.150|60'); + hitKeys.add(second); + // Third strike: both consumed → wrong note. + assert.equal(judgeHit(notes, 60, 1.1, hitKeys, TOL), null); +}); + +test('judgeHit: empty/absent chart never judges', () => { + const { judgeHit } = load(); + assert.equal(judgeHit([], 60, 1.0, new Set(), TOL), null); + assert.equal(judgeHit(null, 60, 1.0, new Set(), TOL), null); +}); + +test('sweepMissed: marks elapsed unhit notes once, respects hit + floor', () => { + const { sweepMissed, noteKey } = load(); + const notes = [ + { midi: 60, t: 1.0 }, + { midi: 62, t: 1.5 }, + { midi: 64, t: 5.0 }, + ]; + const hitKeys = new Set([noteKey(1.0, 60)]); // 60@1.0 was hit + const missedKeys = new Set(); + const missed = []; + // At t=2.0 the windows for 1.0 and 1.5 have elapsed; 5.0 is pending. + const n1 = sweepMissed(notes, 2.0, hitKeys, missedKeys, TOL, null, n => missed.push(n.midi)); + assert.equal(n1, 1); + assert.deepEqual(missed, [62]); + assert.ok(missedKeys.has(noteKey(1.5, 62))); + // Sweeping again counts nothing new (idempotent per note). + assert.equal(sweepMissed(notes, 2.1, hitKeys, missedKeys, TOL, null), 0); + // Floor: a device connected at t=6 must not retro-miss the 5.0 note. + const hk2 = new Set(), mk2 = new Set(); + assert.equal(sweepMissed(notes, 6.0, hk2, mk2, TOL, 6.0), 0); +}); + +test('sweepMissed: a note exactly at the connect floor is not retro-missed', () => { + // Off-by-one guard: floor is the connect instant; a note whose onset + // equals it (device connected exactly as the onset passed) must be + // excluded, not swept. Floor comparison is `<=`, not `<`. + const { sweepMissed } = load(); + const notes = [{ midi: 60, t: 5.0 }, { midi: 62, t: 6.0 }]; + const missedKeys = new Set(); + const missed = []; + const n = sweepMissed(notes, 7.0, new Set(), missedKeys, TOL, 5.0, + m => missed.push(m.midi)); + assert.equal(n, 1); + assert.deepEqual(missed, [62]); +}); + +test('sweepMissed: a long frame stall cannot let elapsed notes slip past', () => { + const { sweepMissed } = load(); + const notes = [{ midi: 60, t: 1.0 }, { midi: 62, t: 3.0 }]; + const missedKeys = new Set(); + // The previous sweep ran at t≈0; the next runs 10 s later (backgrounded + // tab / render hitch). Both elapsed notes must still be counted. + assert.equal(sweepMissed(notes, 10.0, new Set(), missedKeys, TOL, null), 2); +}); + +test('sweepMissed: cursor advances monotonically and never recounts', () => { + const { sweepMissed } = load(); + const notes = [ + { midi: 60, t: 1.0 }, + { midi: 62, t: 2.0 }, + { midi: 64, t: 9.0 }, + ]; + const hitKeys = new Set(), missedKeys = new Set(); + const cursor = { idx: 0 }; + assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 1); + assert.equal(cursor.idx, 1); + // Stall to t=8: the 2.0 note is counted exactly once from the cursor. + assert.equal(sweepMissed(notes, 8.0, hitKeys, missedKeys, TOL, null, null, cursor), 1); + assert.equal(cursor.idx, 2); + // Seek BACKWARDS: the cursor does not rewind, nothing is recounted. + assert.equal(sweepMissed(notes, 1.5, hitKeys, missedKeys, TOL, null, null, cursor), 0); + // The cursor still advances past pre-floor notes without counting them. + const c2 = { idx: 0 }; + const mk2 = new Set(); + assert.equal(sweepMissed(notes, 8.0, new Set(), mk2, TOL, 5.0, null, c2), 0); + assert.equal(c2.idx, 2); +}); + +test('noteKey quantises time to ms so float drift cannot double-count', () => { + const { noteKey } = load(); + assert.equal(noteKey(1.0004, 60), noteKey(1.0001, 60)); + assert.notEqual(noteKey(1.002, 60), noteKey(1.0001, 60)); + assert.notEqual(noteKey(1.0, 60), noteKey(1.0, 61)); +}); diff --git a/static/tailwind.min.css b/static/tailwind.min.css index 9d22dd9..b2a8412 100644 --- a/static/tailwind.min.css +++ b/static/tailwind.min.css @@ -1,2 +1,2 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-left-\[1\.4375rem\]{left:-1.4375rem}.-right-\[1\.4375rem\]{right:-1.4375rem}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.bottom-5{bottom:1.25rem}.bottom-6{bottom:1.5rem}.bottom-\[30\%\]{bottom:30%}.bottom-full{bottom:100%}.left-0{left:0}.left-0\.5{left:.125rem}.left-1\.5{left:.375rem}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.left-9{left:2.25rem}.left-\[30\%\]{left:30%}.right-0{right:0}.right-0\.5{right:.125rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.right-5{right:1.25rem}.right-\[30\%\]{right:30%}.top-0{top:0}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-\[30\%\]{top:30%}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1000\]{z-index:1000}.z-\[1001\]{z-index:1001}.z-\[100\]{z-index:100}.z-\[120\]{z-index:120}.z-\[200\]{z-index:200}.z-\[210\]{z-index:210}.z-\[250\]{z-index:250}.z-\[300\]{z-index:300}.z-\[60\]{z-index:60}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.m-1{margin:.25rem}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-auto{margin-left:auto}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.\!block{display:block!important}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[3\/1\]{aspect-ratio:3/1}.aspect-square{aspect-ratio:1/1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-1\/2{height:50%}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-2\/3{height:66.666667%}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[3px\]{height:3px}.h-\[92px\]{height:92px}.h-\[calc\(50\%-1\.5px\)\]{height:calc(50% - 1.5px)}.h-\[calc\(50\%-2\.5px\)\]{height:calc(50% - 2.5px)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-64{max-height:16rem}.max-h-96{max-height:24rem}.max-h-\[80vh\]{max-height:80vh}.min-h-5{min-height:1.25rem}.min-h-6{min-height:1.5rem}.min-h-\[18rem\]{min-height:18rem}.min-h-\[2\.5em\]{min-height:2.5em}.min-h-\[480px\]{min-height:480px}.w-0\.5{width:.125rem}.w-1{width:.25rem}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-2\/3{width:66.666667%}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[96px\]{width:96px}.w-\[calc\(50\%-1px\)\]{width:calc(50% - 1px)}.w-\[calc\(50\%-2px\)\]{width:calc(50% - 2px)}.w-full{width:100%}.min-w-0{min-width:0}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.75rem\]{min-width:1.75rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[180px\]{min-width:180px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[11rem\]{max-width:11rem}.max-w-\[130px\]{max-width:130px}.max-w-\[160px\]{max-width:160px}.max-w-\[210px\]{max-width:210px}.max-w-\[5\.5rem\]{max-width:5.5rem}.max-w-\[90\%\]{max-width:90%}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-x-full{--tw-translate-x:100%}.-rotate-12,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-12{--tw-rotate:-12deg}.rotate-0{--tw-rotate:0deg}.rotate-0,.rotate-12{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-12{--tw-rotate:12deg}.rotate-45{--tw-rotate:45deg}.rotate-45,.rotate-90{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate:90deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-\[3px\]{gap:3px}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-fb-border\/30>:not([hidden])~:not([hidden]){border-color:rgba(51,65,85,.3)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.rounded-bl-md{border-bottom-left-radius:.375rem}.rounded-br-md{border-bottom-right-radius:.375rem}.rounded-tl-md{border-top-left-radius:.375rem}.rounded-tr-md{border-top-right-radius:.375rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-x{border-left-width:1px;border-right-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-t-0{border-top-width:0}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-accent{--tw-border-opacity:1;border-color:rgb(64 128 224/var(--tw-border-opacity,1))}.border-accent-dark{--tw-border-opacity:1;border-color:rgb(32 96 176/var(--tw-border-opacity,1))}.border-accent-dark\/0{border-color:rgba(32,96,176,0)}.border-accent-dark\/10{border-color:rgba(32,96,176,.1)}.border-accent-dark\/100{border-color:#2060b0}.border-accent-dark\/15{border-color:rgba(32,96,176,.15)}.border-accent-dark\/20{border-color:rgba(32,96,176,.2)}.border-accent-dark\/25{border-color:rgba(32,96,176,.25)}.border-accent-dark\/30{border-color:rgba(32,96,176,.3)}.border-accent-dark\/35{border-color:rgba(32,96,176,.35)}.border-accent-dark\/40{border-color:rgba(32,96,176,.4)}.border-accent-dark\/45{border-color:rgba(32,96,176,.45)}.border-accent-dark\/5{border-color:rgba(32,96,176,.05)}.border-accent-dark\/50{border-color:rgba(32,96,176,.5)}.border-accent-dark\/55{border-color:rgba(32,96,176,.55)}.border-accent-dark\/60{border-color:rgba(32,96,176,.6)}.border-accent-dark\/65{border-color:rgba(32,96,176,.65)}.border-accent-dark\/70{border-color:rgba(32,96,176,.7)}.border-accent-dark\/75{border-color:rgba(32,96,176,.75)}.border-accent-dark\/80{border-color:rgba(32,96,176,.8)}.border-accent-dark\/85{border-color:rgba(32,96,176,.85)}.border-accent-dark\/90{border-color:rgba(32,96,176,.9)}.border-accent-dark\/95{border-color:rgba(32,96,176,.95)}.border-accent-light{--tw-border-opacity:1;border-color:rgb(96 160 255/var(--tw-border-opacity,1))}.border-accent-light\/0{border-color:rgba(96,160,255,0)}.border-accent-light\/10{border-color:rgba(96,160,255,.1)}.border-accent-light\/100{border-color:#60a0ff}.border-accent-light\/15{border-color:rgba(96,160,255,.15)}.border-accent-light\/20{border-color:rgba(96,160,255,.2)}.border-accent-light\/25{border-color:rgba(96,160,255,.25)}.border-accent-light\/30{border-color:rgba(96,160,255,.3)}.border-accent-light\/35{border-color:rgba(96,160,255,.35)}.border-accent-light\/40{border-color:rgba(96,160,255,.4)}.border-accent-light\/45{border-color:rgba(96,160,255,.45)}.border-accent-light\/5{border-color:rgba(96,160,255,.05)}.border-accent-light\/50{border-color:rgba(96,160,255,.5)}.border-accent-light\/55{border-color:rgba(96,160,255,.55)}.border-accent-light\/60{border-color:rgba(96,160,255,.6)}.border-accent-light\/65{border-color:rgba(96,160,255,.65)}.border-accent-light\/70{border-color:rgba(96,160,255,.7)}.border-accent-light\/75{border-color:rgba(96,160,255,.75)}.border-accent-light\/80{border-color:rgba(96,160,255,.8)}.border-accent-light\/85{border-color:rgba(96,160,255,.85)}.border-accent-light\/90{border-color:rgba(96,160,255,.9)}.border-accent-light\/95{border-color:rgba(96,160,255,.95)}.border-accent\/20{border-color:rgba(64,128,224,.2)}.border-accent\/40{border-color:rgba(64,128,224,.4)}.border-amber-100{--tw-border-opacity:1;border-color:rgb(254 243 199/var(--tw-border-opacity,1))}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-400{--tw-border-opacity:1;border-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-amber-50{--tw-border-opacity:1;border-color:rgb(255 251 235/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-amber-500\/30{border-color:rgba(245,158,11,.3)}.border-amber-600{--tw-border-opacity:1;border-color:rgb(217 119 6/var(--tw-border-opacity,1))}.border-amber-700{--tw-border-opacity:1;border-color:rgb(180 83 9/var(--tw-border-opacity,1))}.border-amber-700\/60{border-color:rgba(180,83,9,.6)}.border-amber-800{--tw-border-opacity:1;border-color:rgb(146 64 14/var(--tw-border-opacity,1))}.border-amber-800\/60{border-color:rgba(146,64,14,.6)}.border-amber-900{--tw-border-opacity:1;border-color:rgb(120 53 15/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-50{--tw-border-opacity:1;border-color:rgb(239 246 255/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity,1))}.border-blue-700\/60{border-color:rgba(29,78,216,.6)}.border-blue-800{--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity,1))}.border-blue-900{--tw-border-opacity:1;border-color:rgb(30 58 138/var(--tw-border-opacity,1))}.border-dark-500{--tw-border-opacity:1;border-color:rgb(30 30 58/var(--tw-border-opacity,1))}.border-dark-500\/0{border-color:rgba(30,30,58,0)}.border-dark-500\/10{border-color:rgba(30,30,58,.1)}.border-dark-500\/100{border-color:#1e1e3a}.border-dark-500\/15{border-color:rgba(30,30,58,.15)}.border-dark-500\/20{border-color:rgba(30,30,58,.2)}.border-dark-500\/25{border-color:rgba(30,30,58,.25)}.border-dark-500\/30{border-color:rgba(30,30,58,.3)}.border-dark-500\/35{border-color:rgba(30,30,58,.35)}.border-dark-500\/40{border-color:rgba(30,30,58,.4)}.border-dark-500\/45{border-color:rgba(30,30,58,.45)}.border-dark-500\/5{border-color:rgba(30,30,58,.05)}.border-dark-500\/50{border-color:rgba(30,30,58,.5)}.border-dark-500\/55{border-color:rgba(30,30,58,.55)}.border-dark-500\/60{border-color:rgba(30,30,58,.6)}.border-dark-500\/65{border-color:rgba(30,30,58,.65)}.border-dark-500\/70{border-color:rgba(30,30,58,.7)}.border-dark-500\/75{border-color:rgba(30,30,58,.75)}.border-dark-500\/80{border-color:rgba(30,30,58,.8)}.border-dark-500\/85{border-color:rgba(30,30,58,.85)}.border-dark-500\/90{border-color:rgba(30,30,58,.9)}.border-dark-500\/95{border-color:rgba(30,30,58,.95)}.border-dark-600{--tw-border-opacity:1;border-color:rgb(24 24 48/var(--tw-border-opacity,1))}.border-dark-600\/0{border-color:rgba(24,24,48,0)}.border-dark-600\/10{border-color:rgba(24,24,48,.1)}.border-dark-600\/100{border-color:#181830}.border-dark-600\/15{border-color:rgba(24,24,48,.15)}.border-dark-600\/20{border-color:rgba(24,24,48,.2)}.border-dark-600\/25{border-color:rgba(24,24,48,.25)}.border-dark-600\/30{border-color:rgba(24,24,48,.3)}.border-dark-600\/35{border-color:rgba(24,24,48,.35)}.border-dark-600\/40{border-color:rgba(24,24,48,.4)}.border-dark-600\/45{border-color:rgba(24,24,48,.45)}.border-dark-600\/5{border-color:rgba(24,24,48,.05)}.border-dark-600\/50{border-color:rgba(24,24,48,.5)}.border-dark-600\/55{border-color:rgba(24,24,48,.55)}.border-dark-600\/60{border-color:rgba(24,24,48,.6)}.border-dark-600\/65{border-color:rgba(24,24,48,.65)}.border-dark-600\/70{border-color:rgba(24,24,48,.7)}.border-dark-600\/75{border-color:rgba(24,24,48,.75)}.border-dark-600\/80{border-color:rgba(24,24,48,.8)}.border-dark-600\/85{border-color:rgba(24,24,48,.85)}.border-dark-600\/90{border-color:rgba(24,24,48,.9)}.border-dark-600\/95{border-color:rgba(24,24,48,.95)}.border-dark-700{--tw-border-opacity:1;border-color:rgb(16 16 30/var(--tw-border-opacity,1))}.border-dark-700\/0{border-color:rgba(16,16,30,0)}.border-dark-700\/10{border-color:rgba(16,16,30,.1)}.border-dark-700\/100{border-color:#10101e}.border-dark-700\/15{border-color:rgba(16,16,30,.15)}.border-dark-700\/20{border-color:rgba(16,16,30,.2)}.border-dark-700\/25{border-color:rgba(16,16,30,.25)}.border-dark-700\/30{border-color:rgba(16,16,30,.3)}.border-dark-700\/35{border-color:rgba(16,16,30,.35)}.border-dark-700\/40{border-color:rgba(16,16,30,.4)}.border-dark-700\/45{border-color:rgba(16,16,30,.45)}.border-dark-700\/5{border-color:rgba(16,16,30,.05)}.border-dark-700\/50{border-color:rgba(16,16,30,.5)}.border-dark-700\/55{border-color:rgba(16,16,30,.55)}.border-dark-700\/60{border-color:rgba(16,16,30,.6)}.border-dark-700\/65{border-color:rgba(16,16,30,.65)}.border-dark-700\/70{border-color:rgba(16,16,30,.7)}.border-dark-700\/75{border-color:rgba(16,16,30,.75)}.border-dark-700\/80{border-color:rgba(16,16,30,.8)}.border-dark-700\/85{border-color:rgba(16,16,30,.85)}.border-dark-700\/90{border-color:rgba(16,16,30,.9)}.border-dark-700\/95{border-color:rgba(16,16,30,.95)}.border-dark-800{--tw-border-opacity:1;border-color:rgb(10 10 18/var(--tw-border-opacity,1))}.border-dark-800\/0{border-color:rgba(10,10,18,0)}.border-dark-800\/10{border-color:rgba(10,10,18,.1)}.border-dark-800\/100{border-color:#0a0a12}.border-dark-800\/15{border-color:rgba(10,10,18,.15)}.border-dark-800\/20{border-color:rgba(10,10,18,.2)}.border-dark-800\/25{border-color:rgba(10,10,18,.25)}.border-dark-800\/30{border-color:rgba(10,10,18,.3)}.border-dark-800\/35{border-color:rgba(10,10,18,.35)}.border-dark-800\/40{border-color:rgba(10,10,18,.4)}.border-dark-800\/45{border-color:rgba(10,10,18,.45)}.border-dark-800\/5{border-color:rgba(10,10,18,.05)}.border-dark-800\/50{border-color:rgba(10,10,18,.5)}.border-dark-800\/55{border-color:rgba(10,10,18,.55)}.border-dark-800\/60{border-color:rgba(10,10,18,.6)}.border-dark-800\/65{border-color:rgba(10,10,18,.65)}.border-dark-800\/70{border-color:rgba(10,10,18,.7)}.border-dark-800\/75{border-color:rgba(10,10,18,.75)}.border-dark-800\/80{border-color:rgba(10,10,18,.8)}.border-dark-800\/85{border-color:rgba(10,10,18,.85)}.border-dark-800\/90{border-color:rgba(10,10,18,.9)}.border-dark-800\/95{border-color:rgba(10,10,18,.95)}.border-dark-900{--tw-border-opacity:1;border-color:rgb(5 5 8/var(--tw-border-opacity,1))}.border-dark-900\/0{border-color:rgba(5,5,8,0)}.border-dark-900\/10{border-color:rgba(5,5,8,.1)}.border-dark-900\/100{border-color:#050508}.border-dark-900\/15{border-color:rgba(5,5,8,.15)}.border-dark-900\/20{border-color:rgba(5,5,8,.2)}.border-dark-900\/25{border-color:rgba(5,5,8,.25)}.border-dark-900\/30{border-color:rgba(5,5,8,.3)}.border-dark-900\/35{border-color:rgba(5,5,8,.35)}.border-dark-900\/40{border-color:rgba(5,5,8,.4)}.border-dark-900\/45{border-color:rgba(5,5,8,.45)}.border-dark-900\/5{border-color:rgba(5,5,8,.05)}.border-dark-900\/50{border-color:rgba(5,5,8,.5)}.border-dark-900\/55{border-color:rgba(5,5,8,.55)}.border-dark-900\/60{border-color:rgba(5,5,8,.6)}.border-dark-900\/65{border-color:rgba(5,5,8,.65)}.border-dark-900\/70{border-color:rgba(5,5,8,.7)}.border-dark-900\/75{border-color:rgba(5,5,8,.75)}.border-dark-900\/80{border-color:rgba(5,5,8,.8)}.border-dark-900\/85{border-color:rgba(5,5,8,.85)}.border-dark-900\/90{border-color:rgba(5,5,8,.9)}.border-dark-900\/95{border-color:rgba(5,5,8,.95)}.border-emerald-700\/60{border-color:rgba(4,120,87,.6)}.border-emerald-800\/60{border-color:rgba(6,95,70,.6)}.border-fb-border{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-fb-border\/30{border-color:rgba(51,65,85,.3)}.border-fb-border\/40{border-color:rgba(51,65,85,.4)}.border-fb-border\/50{border-color:rgba(51,65,85,.5)}.border-fb-border\/60{border-color:rgba(51,65,85,.6)}.border-fb-border\/70{border-color:rgba(51,65,85,.7)}.border-fb-good\/40{border-color:rgba(34,197,94,.4)}.border-fb-good\/50{border-color:rgba(34,197,94,.5)}.border-fb-low\/40{border-color:rgba(239,68,68,.4)}.border-fb-primary{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-fb-primary\/50{border-color:rgba(14,165,233,.5)}.border-fb-textDim{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-gold{--tw-border-opacity:1;border-color:rgb(232 192 64/var(--tw-border-opacity,1))}.border-gold\/20{border-color:rgba(232,192,64,.2)}.border-gold\/40{border-color:rgba(232,192,64,.4)}.border-gold\/50{border-color:rgba(232,192,64,.5)}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.border-gray-800\/20{border-color:rgba(31,41,55,.2)}.border-gray-800\/50{border-color:rgba(31,41,55,.5)}.border-gray-800\/70{border-color:rgba(31,41,55,.7)}.border-gray-900{--tw-border-opacity:1;border-color:rgb(17 24 39/var(--tw-border-opacity,1))}.border-green-100{--tw-border-opacity:1;border-color:rgb(220 252 231/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity,1))}.border-green-400{--tw-border-opacity:1;border-color:rgb(74 222 128/var(--tw-border-opacity,1))}.border-green-50{--tw-border-opacity:1;border-color:rgb(240 253 244/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-green-600{--tw-border-opacity:1;border-color:rgb(22 163 74/var(--tw-border-opacity,1))}.border-green-700{--tw-border-opacity:1;border-color:rgb(21 128 61/var(--tw-border-opacity,1))}.border-green-800{--tw-border-opacity:1;border-color:rgb(22 101 52/var(--tw-border-opacity,1))}.border-green-900{--tw-border-opacity:1;border-color:rgb(20 83 45/var(--tw-border-opacity,1))}.border-indigo-100{--tw-border-opacity:1;border-color:rgb(224 231 255/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-indigo-300{--tw-border-opacity:1;border-color:rgb(165 180 252/var(--tw-border-opacity,1))}.border-indigo-400{--tw-border-opacity:1;border-color:rgb(129 140 248/var(--tw-border-opacity,1))}.border-indigo-50{--tw-border-opacity:1;border-color:rgb(238 242 255/var(--tw-border-opacity,1))}.border-indigo-500{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-indigo-700{--tw-border-opacity:1;border-color:rgb(67 56 202/var(--tw-border-opacity,1))}.border-indigo-800{--tw-border-opacity:1;border-color:rgb(55 48 163/var(--tw-border-opacity,1))}.border-indigo-900{--tw-border-opacity:1;border-color:rgb(49 46 129/var(--tw-border-opacity,1))}.border-orange-200{--tw-border-opacity:1;border-color:rgb(254 215 170/var(--tw-border-opacity,1))}.border-pink-100{--tw-border-opacity:1;border-color:rgb(252 231 243/var(--tw-border-opacity,1))}.border-pink-200{--tw-border-opacity:1;border-color:rgb(251 207 232/var(--tw-border-opacity,1))}.border-pink-300{--tw-border-opacity:1;border-color:rgb(249 168 212/var(--tw-border-opacity,1))}.border-pink-400{--tw-border-opacity:1;border-color:rgb(244 114 182/var(--tw-border-opacity,1))}.border-pink-50{--tw-border-opacity:1;border-color:rgb(253 242 248/var(--tw-border-opacity,1))}.border-pink-500{--tw-border-opacity:1;border-color:rgb(236 72 153/var(--tw-border-opacity,1))}.border-pink-600{--tw-border-opacity:1;border-color:rgb(219 39 119/var(--tw-border-opacity,1))}.border-pink-700{--tw-border-opacity:1;border-color:rgb(190 24 93/var(--tw-border-opacity,1))}.border-pink-800{--tw-border-opacity:1;border-color:rgb(157 23 77/var(--tw-border-opacity,1))}.border-pink-900{--tw-border-opacity:1;border-color:rgb(131 24 67/var(--tw-border-opacity,1))}.border-purple-100{--tw-border-opacity:1;border-color:rgb(243 232 255/var(--tw-border-opacity,1))}.border-purple-200{--tw-border-opacity:1;border-color:rgb(233 213 255/var(--tw-border-opacity,1))}.border-purple-300{--tw-border-opacity:1;border-color:rgb(216 180 254/var(--tw-border-opacity,1))}.border-purple-400{--tw-border-opacity:1;border-color:rgb(192 132 252/var(--tw-border-opacity,1))}.border-purple-400\/30{border-color:rgba(192,132,252,.3)}.border-purple-400\/40{border-color:rgba(192,132,252,.4)}.border-purple-50{--tw-border-opacity:1;border-color:rgb(250 245 255/var(--tw-border-opacity,1))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-purple-600{--tw-border-opacity:1;border-color:rgb(147 51 234/var(--tw-border-opacity,1))}.border-purple-700{--tw-border-opacity:1;border-color:rgb(126 34 206/var(--tw-border-opacity,1))}.border-purple-800{--tw-border-opacity:1;border-color:rgb(107 33 168/var(--tw-border-opacity,1))}.border-purple-900{--tw-border-opacity:1;border-color:rgb(88 28 135/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity,1))}.border-red-50{--tw-border-opacity:1;border-color:rgb(254 242 242/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-500\/40{border-color:rgba(239,68,68,.4)}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.border-red-700\/60{border-color:rgba(185,28,28,.6)}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-red-800\/70{border-color:rgba(153,27,27,.7)}.border-red-900{--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.border-red-900\/30{border-color:rgba(127,29,29,.3)}.border-red-900\/50{border-color:rgba(127,29,29,.5)}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-slate-900{--tw-border-opacity:1;border-color:rgb(15 23 42/var(--tw-border-opacity,1))}.border-teal-700\/60{border-color:rgba(15,118,110,.6)}.border-violet-100{--tw-border-opacity:1;border-color:rgb(237 233 254/var(--tw-border-opacity,1))}.border-yellow-100{--tw-border-opacity:1;border-color:rgb(254 249 195/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-400\/40{border-color:rgba(250,204,21,.4)}.border-yellow-50{--tw-border-opacity:1;border-color:rgb(254 252 232/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-yellow-600{--tw-border-opacity:1;border-color:rgb(202 138 4/var(--tw-border-opacity,1))}.border-yellow-700{--tw-border-opacity:1;border-color:rgb(161 98 7/var(--tw-border-opacity,1))}.border-yellow-800{--tw-border-opacity:1;border-color:rgb(133 77 14/var(--tw-border-opacity,1))}.border-yellow-900{--tw-border-opacity:1;border-color:rgb(113 63 18/var(--tw-border-opacity,1))}.\!bg-accent{--tw-bg-opacity:1!important;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))!important}.bg-accent{--tw-bg-opacity:1;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))}.bg-accent-dark{--tw-bg-opacity:1;background-color:rgb(32 96 176/var(--tw-bg-opacity,1))}.bg-accent-dark\/0{background-color:rgba(32,96,176,0)}.bg-accent-dark\/10{background-color:rgba(32,96,176,.1)}.bg-accent-dark\/100{background-color:#2060b0}.bg-accent-dark\/15{background-color:rgba(32,96,176,.15)}.bg-accent-dark\/20{background-color:rgba(32,96,176,.2)}.bg-accent-dark\/25{background-color:rgba(32,96,176,.25)}.bg-accent-dark\/30{background-color:rgba(32,96,176,.3)}.bg-accent-dark\/35{background-color:rgba(32,96,176,.35)}.bg-accent-dark\/40{background-color:rgba(32,96,176,.4)}.bg-accent-dark\/45{background-color:rgba(32,96,176,.45)}.bg-accent-dark\/5{background-color:rgba(32,96,176,.05)}.bg-accent-dark\/50{background-color:rgba(32,96,176,.5)}.bg-accent-dark\/55{background-color:rgba(32,96,176,.55)}.bg-accent-dark\/60{background-color:rgba(32,96,176,.6)}.bg-accent-dark\/65{background-color:rgba(32,96,176,.65)}.bg-accent-dark\/70{background-color:rgba(32,96,176,.7)}.bg-accent-dark\/75{background-color:rgba(32,96,176,.75)}.bg-accent-dark\/80{background-color:rgba(32,96,176,.8)}.bg-accent-dark\/85{background-color:rgba(32,96,176,.85)}.bg-accent-dark\/90{background-color:rgba(32,96,176,.9)}.bg-accent-dark\/95{background-color:rgba(32,96,176,.95)}.bg-accent-light{--tw-bg-opacity:1;background-color:rgb(96 160 255/var(--tw-bg-opacity,1))}.bg-accent-light\/0{background-color:rgba(96,160,255,0)}.bg-accent-light\/10{background-color:rgba(96,160,255,.1)}.bg-accent-light\/100{background-color:#60a0ff}.bg-accent-light\/15{background-color:rgba(96,160,255,.15)}.bg-accent-light\/20{background-color:rgba(96,160,255,.2)}.bg-accent-light\/25{background-color:rgba(96,160,255,.25)}.bg-accent-light\/30{background-color:rgba(96,160,255,.3)}.bg-accent-light\/35{background-color:rgba(96,160,255,.35)}.bg-accent-light\/40{background-color:rgba(96,160,255,.4)}.bg-accent-light\/45{background-color:rgba(96,160,255,.45)}.bg-accent-light\/5{background-color:rgba(96,160,255,.05)}.bg-accent-light\/50{background-color:rgba(96,160,255,.5)}.bg-accent-light\/55{background-color:rgba(96,160,255,.55)}.bg-accent-light\/60{background-color:rgba(96,160,255,.6)}.bg-accent-light\/65{background-color:rgba(96,160,255,.65)}.bg-accent-light\/70{background-color:rgba(96,160,255,.7)}.bg-accent-light\/75{background-color:rgba(96,160,255,.75)}.bg-accent-light\/80{background-color:rgba(96,160,255,.8)}.bg-accent-light\/85{background-color:rgba(96,160,255,.85)}.bg-accent-light\/90{background-color:rgba(96,160,255,.9)}.bg-accent-light\/95{background-color:rgba(96,160,255,.95)}.bg-accent\/10{background-color:rgba(64,128,224,.1)}.bg-accent\/20{background-color:rgba(64,128,224,.2)}.bg-accent\/30{background-color:rgba(64,128,224,.3)}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-300{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity,1))}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-500\/5{background-color:rgba(245,158,11,.05)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-amber-700{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.bg-amber-800{--tw-bg-opacity:1;background-color:rgb(146 64 14/var(--tw-bg-opacity,1))}.bg-amber-900{--tw-bg-opacity:1;background-color:rgb(120 53 15/var(--tw-bg-opacity,1))}.bg-amber-900\/30{background-color:rgba(120,53,15,.3)}.bg-amber-900\/60{background-color:rgba(120,53,15,.6)}.bg-amber-900\/80{background-color:rgba(120,53,15,.8)}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/55{background-color:rgba(0,0,0,.55)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-black\/70{background-color:rgba(0,0,0,.7)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.bg-blue-300{--tw-bg-opacity:1;background-color:rgb(147 197 253/var(--tw-bg-opacity,1))}.bg-blue-400{--tw-bg-opacity:1;background-color:rgb(96 165 250/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.bg-blue-800{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-blue-900\/40{background-color:rgba(30,58,138,.4)}.bg-current{background-color:currentColor}.bg-cyan-400{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.bg-dark-500{--tw-bg-opacity:1;background-color:rgb(30 30 58/var(--tw-bg-opacity,1))}.bg-dark-500\/0{background-color:rgba(30,30,58,0)}.bg-dark-500\/10{background-color:rgba(30,30,58,.1)}.bg-dark-500\/100{background-color:#1e1e3a}.bg-dark-500\/15{background-color:rgba(30,30,58,.15)}.bg-dark-500\/20{background-color:rgba(30,30,58,.2)}.bg-dark-500\/25{background-color:rgba(30,30,58,.25)}.bg-dark-500\/30{background-color:rgba(30,30,58,.3)}.bg-dark-500\/35{background-color:rgba(30,30,58,.35)}.bg-dark-500\/40{background-color:rgba(30,30,58,.4)}.bg-dark-500\/45{background-color:rgba(30,30,58,.45)}.bg-dark-500\/5{background-color:rgba(30,30,58,.05)}.bg-dark-500\/50{background-color:rgba(30,30,58,.5)}.bg-dark-500\/55{background-color:rgba(30,30,58,.55)}.bg-dark-500\/60{background-color:rgba(30,30,58,.6)}.bg-dark-500\/65{background-color:rgba(30,30,58,.65)}.bg-dark-500\/70{background-color:rgba(30,30,58,.7)}.bg-dark-500\/75{background-color:rgba(30,30,58,.75)}.bg-dark-500\/80{background-color:rgba(30,30,58,.8)}.bg-dark-500\/85{background-color:rgba(30,30,58,.85)}.bg-dark-500\/90{background-color:rgba(30,30,58,.9)}.bg-dark-500\/95{background-color:rgba(30,30,58,.95)}.bg-dark-600{--tw-bg-opacity:1;background-color:rgb(24 24 48/var(--tw-bg-opacity,1))}.bg-dark-600\/0{background-color:rgba(24,24,48,0)}.bg-dark-600\/10{background-color:rgba(24,24,48,.1)}.bg-dark-600\/100{background-color:#181830}.bg-dark-600\/15{background-color:rgba(24,24,48,.15)}.bg-dark-600\/20{background-color:rgba(24,24,48,.2)}.bg-dark-600\/25{background-color:rgba(24,24,48,.25)}.bg-dark-600\/30{background-color:rgba(24,24,48,.3)}.bg-dark-600\/35{background-color:rgba(24,24,48,.35)}.bg-dark-600\/40{background-color:rgba(24,24,48,.4)}.bg-dark-600\/45{background-color:rgba(24,24,48,.45)}.bg-dark-600\/5{background-color:rgba(24,24,48,.05)}.bg-dark-600\/50{background-color:rgba(24,24,48,.5)}.bg-dark-600\/55{background-color:rgba(24,24,48,.55)}.bg-dark-600\/60{background-color:rgba(24,24,48,.6)}.bg-dark-600\/65{background-color:rgba(24,24,48,.65)}.bg-dark-600\/70{background-color:rgba(24,24,48,.7)}.bg-dark-600\/75{background-color:rgba(24,24,48,.75)}.bg-dark-600\/80{background-color:rgba(24,24,48,.8)}.bg-dark-600\/85{background-color:rgba(24,24,48,.85)}.bg-dark-600\/90{background-color:rgba(24,24,48,.9)}.bg-dark-600\/95{background-color:rgba(24,24,48,.95)}.bg-dark-700{--tw-bg-opacity:1;background-color:rgb(16 16 30/var(--tw-bg-opacity,1))}.bg-dark-700\/0{background-color:rgba(16,16,30,0)}.bg-dark-700\/10{background-color:rgba(16,16,30,.1)}.bg-dark-700\/100{background-color:#10101e}.bg-dark-700\/15{background-color:rgba(16,16,30,.15)}.bg-dark-700\/20{background-color:rgba(16,16,30,.2)}.bg-dark-700\/25{background-color:rgba(16,16,30,.25)}.bg-dark-700\/30{background-color:rgba(16,16,30,.3)}.bg-dark-700\/35{background-color:rgba(16,16,30,.35)}.bg-dark-700\/40{background-color:rgba(16,16,30,.4)}.bg-dark-700\/45{background-color:rgba(16,16,30,.45)}.bg-dark-700\/5{background-color:rgba(16,16,30,.05)}.bg-dark-700\/50{background-color:rgba(16,16,30,.5)}.bg-dark-700\/55{background-color:rgba(16,16,30,.55)}.bg-dark-700\/60{background-color:rgba(16,16,30,.6)}.bg-dark-700\/65{background-color:rgba(16,16,30,.65)}.bg-dark-700\/70{background-color:rgba(16,16,30,.7)}.bg-dark-700\/75{background-color:rgba(16,16,30,.75)}.bg-dark-700\/80{background-color:rgba(16,16,30,.8)}.bg-dark-700\/85{background-color:rgba(16,16,30,.85)}.bg-dark-700\/90{background-color:rgba(16,16,30,.9)}.bg-dark-700\/95{background-color:rgba(16,16,30,.95)}.bg-dark-800{--tw-bg-opacity:1;background-color:rgb(10 10 18/var(--tw-bg-opacity,1))}.bg-dark-800\/0{background-color:rgba(10,10,18,0)}.bg-dark-800\/10{background-color:rgba(10,10,18,.1)}.bg-dark-800\/100{background-color:#0a0a12}.bg-dark-800\/15{background-color:rgba(10,10,18,.15)}.bg-dark-800\/20{background-color:rgba(10,10,18,.2)}.bg-dark-800\/25{background-color:rgba(10,10,18,.25)}.bg-dark-800\/30{background-color:rgba(10,10,18,.3)}.bg-dark-800\/35{background-color:rgba(10,10,18,.35)}.bg-dark-800\/40{background-color:rgba(10,10,18,.4)}.bg-dark-800\/45{background-color:rgba(10,10,18,.45)}.bg-dark-800\/5{background-color:rgba(10,10,18,.05)}.bg-dark-800\/50{background-color:rgba(10,10,18,.5)}.bg-dark-800\/55{background-color:rgba(10,10,18,.55)}.bg-dark-800\/60{background-color:rgba(10,10,18,.6)}.bg-dark-800\/65{background-color:rgba(10,10,18,.65)}.bg-dark-800\/70{background-color:rgba(10,10,18,.7)}.bg-dark-800\/75{background-color:rgba(10,10,18,.75)}.bg-dark-800\/80{background-color:rgba(10,10,18,.8)}.bg-dark-800\/85{background-color:rgba(10,10,18,.85)}.bg-dark-800\/90{background-color:rgba(10,10,18,.9)}.bg-dark-800\/95{background-color:rgba(10,10,18,.95)}.bg-dark-900{--tw-bg-opacity:1;background-color:rgb(5 5 8/var(--tw-bg-opacity,1))}.bg-dark-900\/0{background-color:rgba(5,5,8,0)}.bg-dark-900\/10{background-color:rgba(5,5,8,.1)}.bg-dark-900\/100{background-color:#050508}.bg-dark-900\/15{background-color:rgba(5,5,8,.15)}.bg-dark-900\/20{background-color:rgba(5,5,8,.2)}.bg-dark-900\/25{background-color:rgba(5,5,8,.25)}.bg-dark-900\/30{background-color:rgba(5,5,8,.3)}.bg-dark-900\/35{background-color:rgba(5,5,8,.35)}.bg-dark-900\/40{background-color:rgba(5,5,8,.4)}.bg-dark-900\/45{background-color:rgba(5,5,8,.45)}.bg-dark-900\/5{background-color:rgba(5,5,8,.05)}.bg-dark-900\/50{background-color:rgba(5,5,8,.5)}.bg-dark-900\/55{background-color:rgba(5,5,8,.55)}.bg-dark-900\/60{background-color:rgba(5,5,8,.6)}.bg-dark-900\/65{background-color:rgba(5,5,8,.65)}.bg-dark-900\/70{background-color:rgba(5,5,8,.7)}.bg-dark-900\/75{background-color:rgba(5,5,8,.75)}.bg-dark-900\/80{background-color:rgba(5,5,8,.8)}.bg-dark-900\/85{background-color:rgba(5,5,8,.85)}.bg-dark-900\/90{background-color:rgba(5,5,8,.9)}.bg-dark-900\/95{background-color:rgba(5,5,8,.95)}.bg-emerald-400{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-emerald-500\/5{background-color:rgba(16,185,129,.05)}.bg-emerald-700{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.bg-fb-bg{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-fb-bg\/40{background-color:rgba(15,23,42,.4)}.bg-fb-bg\/50{background-color:rgba(15,23,42,.5)}.bg-fb-bg\/60{background-color:rgba(15,23,42,.6)}.bg-fb-bg\/95{background-color:rgba(15,23,42,.95)}.bg-fb-border{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-fb-card{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-fb-card\/60{background-color:rgba(30,41,59,.6)}.bg-fb-card\/80{background-color:rgba(30,41,59,.8)}.bg-fb-cardMuted{--tw-bg-opacity:1;background-color:rgb(11 18 32/var(--tw-bg-opacity,1))}.bg-fb-good{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-fb-good\/30{background-color:rgba(34,197,94,.3)}.bg-fb-low{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-fb-low\/30{background-color:rgba(239,68,68,.3)}.bg-fb-mid{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-fb-primary{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-fb-primary\/20{background-color:rgba(14,165,233,.2)}.bg-fb-sidebar{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-fb-sidebar\/80{background-color:rgba(17,24,39,.8)}.bg-fb-sidebar\/95{background-color:rgba(17,24,39,.95)}.bg-gold{--tw-bg-opacity:1;background-color:rgb(232 192 64/var(--tw-bg-opacity,1))}.bg-gold\/10{background-color:rgba(232,192,64,.1)}.bg-gold\/5{background-color:rgba(232,192,64,.05)}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-500\/40{background-color:hsla(220,9%,46%,.4)}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-800\/50{background-color:rgba(31,41,55,.5)}.bg-gray-800\/60{background-color:rgba(31,41,55,.6)}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.bg-green-300{--tw-bg-opacity:1;background-color:rgb(134 239 172/var(--tw-bg-opacity,1))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-green-700{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.bg-green-800{--tw-bg-opacity:1;background-color:rgb(22 101 52/var(--tw-bg-opacity,1))}.bg-green-900{--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity,1))}.bg-green-900\/30{background-color:rgba(20,83,45,.3)}.bg-green-900\/40{background-color:rgba(20,83,45,.4)}.bg-green-900\/50{background-color:rgba(20,83,45,.5)}.bg-green-900\/60{background-color:rgba(20,83,45,.6)}.bg-green-900\/80{background-color:rgba(20,83,45,.8)}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-200{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.bg-indigo-300{--tw-bg-opacity:1;background-color:rgb(165 180 252/var(--tw-bg-opacity,1))}.bg-indigo-400{--tw-bg-opacity:1;background-color:rgb(129 140 248/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-indigo-700{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.bg-indigo-800{--tw-bg-opacity:1;background-color:rgb(55 48 163/var(--tw-bg-opacity,1))}.bg-indigo-900{--tw-bg-opacity:1;background-color:rgb(49 46 129/var(--tw-bg-opacity,1))}.bg-orange-400{--tw-bg-opacity:1;background-color:rgb(251 146 60/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-600{--tw-bg-opacity:1;background-color:rgb(234 88 12/var(--tw-bg-opacity,1))}.bg-pink-100{--tw-bg-opacity:1;background-color:rgb(252 231 243/var(--tw-bg-opacity,1))}.bg-pink-200{--tw-bg-opacity:1;background-color:rgb(251 207 232/var(--tw-bg-opacity,1))}.bg-pink-300{--tw-bg-opacity:1;background-color:rgb(249 168 212/var(--tw-bg-opacity,1))}.bg-pink-400{--tw-bg-opacity:1;background-color:rgb(244 114 182/var(--tw-bg-opacity,1))}.bg-pink-50{--tw-bg-opacity:1;background-color:rgb(253 242 248/var(--tw-bg-opacity,1))}.bg-pink-500{--tw-bg-opacity:1;background-color:rgb(236 72 153/var(--tw-bg-opacity,1))}.bg-pink-600{--tw-bg-opacity:1;background-color:rgb(219 39 119/var(--tw-bg-opacity,1))}.bg-pink-700{--tw-bg-opacity:1;background-color:rgb(190 24 93/var(--tw-bg-opacity,1))}.bg-pink-800{--tw-bg-opacity:1;background-color:rgb(157 23 77/var(--tw-bg-opacity,1))}.bg-pink-900{--tw-bg-opacity:1;background-color:rgb(131 24 67/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-200{--tw-bg-opacity:1;background-color:rgb(233 213 255/var(--tw-bg-opacity,1))}.bg-purple-300{--tw-bg-opacity:1;background-color:rgb(216 180 254/var(--tw-bg-opacity,1))}.bg-purple-400{--tw-bg-opacity:1;background-color:rgb(192 132 252/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-purple-500\/10{background-color:rgba(168,85,247,.1)}.bg-purple-500\/20{background-color:rgba(168,85,247,.2)}.bg-purple-500\/40{background-color:rgba(168,85,247,.4)}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-purple-700{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.bg-purple-800{--tw-bg-opacity:1;background-color:rgb(107 33 168/var(--tw-bg-opacity,1))}.bg-purple-900{--tw-bg-opacity:1;background-color:rgb(88 28 135/var(--tw-bg-opacity,1))}.bg-purple-900\/30{background-color:rgba(88,28,135,.3)}.bg-purple-900\/40{background-color:rgba(88,28,135,.4)}.bg-purple-900\/60{background-color:rgba(88,28,135,.6)}.bg-purple-900\/80{background-color:rgba(88,28,135,.8)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-300{--tw-bg-opacity:1;background-color:rgb(252 165 165/var(--tw-bg-opacity,1))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-500\/5{background-color:rgba(239,68,68,.05)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/80{background-color:rgba(220,38,38,.8)}.bg-red-700{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.bg-red-800{--tw-bg-opacity:1;background-color:rgb(153 27 27/var(--tw-bg-opacity,1))}.bg-red-900{--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity,1))}.bg-red-900\/10{background-color:rgba(127,29,29,.1)}.bg-red-900\/30{background-color:rgba(127,29,29,.3)}.bg-red-900\/40{background-color:rgba(127,29,29,.4)}.bg-red-900\/95{background-color:rgba(127,29,29,.95)}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-500{--tw-bg-opacity:1;background-color:rgb(100 116 139/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-teal-500\/10{background-color:rgba(20,184,166,.1)}.bg-transparent{background-color:transparent}.bg-violet-200{--tw-bg-opacity:1;background-color:rgb(221 214 254/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-200{--tw-bg-opacity:1;background-color:rgb(254 240 138/var(--tw-bg-opacity,1))}.bg-yellow-300{--tw-bg-opacity:1;background-color:rgb(253 224 71/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:rgba(234,179,8,.1)}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-yellow-700{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.bg-yellow-800{--tw-bg-opacity:1;background-color:rgb(133 77 14/var(--tw-bg-opacity,1))}.bg-yellow-900{--tw-bg-opacity:1;background-color:rgb(113 63 18/var(--tw-bg-opacity,1))}.bg-yellow-900\/30{background-color:rgba(113,63,18,.3)}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-accent-light{--tw-gradient-from:#60a0ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(96,160,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/70{--tw-gradient-from:rgba(0,0,0,.7) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/80{--tw-gradient-from:rgba(0,0,0,.8) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/90{--tw-gradient-from:rgba(0,0,0,.9) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-black\/40{--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),rgba(0,0,0,.4) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-purple-400{--tw-gradient-to:#c084fc var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.object-cover{-o-object-fit:cover;object-fit:cover}.object-center{-o-object-position:center;object-position:center}.object-right{-o-object-position:right;object-position:right}.p-0{padding:0}.p-1{padding:.25rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-0{padding-left:0}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pr-0{padding-right:0}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-24{padding-top:6rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-9xl{font-size:8rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.\!text-white{--tw-text-opacity:1!important;color:rgb(255 255 255/var(--tw-text-opacity,1))!important}.text-\[\#FF4B4B\]{--tw-text-opacity:1;color:rgb(255 75 75/var(--tw-text-opacity,1))}.text-accent{--tw-text-opacity:1;color:rgb(64 128 224/var(--tw-text-opacity,1))}.text-accent-dark{--tw-text-opacity:1;color:rgb(32 96 176/var(--tw-text-opacity,1))}.text-accent-dark\/0{color:rgba(32,96,176,0)}.text-accent-dark\/10{color:rgba(32,96,176,.1)}.text-accent-dark\/100{color:#2060b0}.text-accent-dark\/15{color:rgba(32,96,176,.15)}.text-accent-dark\/20{color:rgba(32,96,176,.2)}.text-accent-dark\/25{color:rgba(32,96,176,.25)}.text-accent-dark\/30{color:rgba(32,96,176,.3)}.text-accent-dark\/35{color:rgba(32,96,176,.35)}.text-accent-dark\/40{color:rgba(32,96,176,.4)}.text-accent-dark\/45{color:rgba(32,96,176,.45)}.text-accent-dark\/5{color:rgba(32,96,176,.05)}.text-accent-dark\/50{color:rgba(32,96,176,.5)}.text-accent-dark\/55{color:rgba(32,96,176,.55)}.text-accent-dark\/60{color:rgba(32,96,176,.6)}.text-accent-dark\/65{color:rgba(32,96,176,.65)}.text-accent-dark\/70{color:rgba(32,96,176,.7)}.text-accent-dark\/75{color:rgba(32,96,176,.75)}.text-accent-dark\/80{color:rgba(32,96,176,.8)}.text-accent-dark\/85{color:rgba(32,96,176,.85)}.text-accent-dark\/90{color:rgba(32,96,176,.9)}.text-accent-dark\/95{color:rgba(32,96,176,.95)}.text-accent-light{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.text-accent-light\/0{color:rgba(96,160,255,0)}.text-accent-light\/10{color:rgba(96,160,255,.1)}.text-accent-light\/100{color:#60a0ff}.text-accent-light\/15{color:rgba(96,160,255,.15)}.text-accent-light\/20{color:rgba(96,160,255,.2)}.text-accent-light\/25{color:rgba(96,160,255,.25)}.text-accent-light\/30{color:rgba(96,160,255,.3)}.text-accent-light\/35{color:rgba(96,160,255,.35)}.text-accent-light\/40{color:rgba(96,160,255,.4)}.text-accent-light\/45{color:rgba(96,160,255,.45)}.text-accent-light\/5{color:rgba(96,160,255,.05)}.text-accent-light\/50{color:rgba(96,160,255,.5)}.text-accent-light\/55{color:rgba(96,160,255,.55)}.text-accent-light\/60{color:rgba(96,160,255,.6)}.text-accent-light\/65{color:rgba(96,160,255,.65)}.text-accent-light\/70{color:rgba(96,160,255,.7)}.text-accent-light\/75{color:rgba(96,160,255,.75)}.text-accent-light\/80{color:rgba(96,160,255,.8)}.text-accent-light\/85{color:rgba(96,160,255,.85)}.text-accent-light\/90{color:rgba(96,160,255,.9)}.text-accent-light\/95{color:rgba(96,160,255,.95)}.text-accent\/70{color:rgba(64,128,224,.7)}.text-amber-100{--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-400\/90{color:rgba(251,191,36,.9)}.text-amber-50{--tw-text-opacity:1;color:rgb(255 251 235/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-100{--tw-text-opacity:1;color:rgb(219 234 254/var(--tw-text-opacity,1))}.text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-400\/70{color:rgba(96,165,250,.7)}.text-blue-50{--tw-text-opacity:1;color:rgb(239 246 255/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-cyan-300{--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.text-cyan-300\/90{color:rgba(103,232,249,.9)}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-dark-500{--tw-text-opacity:1;color:rgb(30 30 58/var(--tw-text-opacity,1))}.text-dark-500\/0{color:rgba(30,30,58,0)}.text-dark-500\/10{color:rgba(30,30,58,.1)}.text-dark-500\/100{color:#1e1e3a}.text-dark-500\/15{color:rgba(30,30,58,.15)}.text-dark-500\/20{color:rgba(30,30,58,.2)}.text-dark-500\/25{color:rgba(30,30,58,.25)}.text-dark-500\/30{color:rgba(30,30,58,.3)}.text-dark-500\/35{color:rgba(30,30,58,.35)}.text-dark-500\/40{color:rgba(30,30,58,.4)}.text-dark-500\/45{color:rgba(30,30,58,.45)}.text-dark-500\/5{color:rgba(30,30,58,.05)}.text-dark-500\/50{color:rgba(30,30,58,.5)}.text-dark-500\/55{color:rgba(30,30,58,.55)}.text-dark-500\/60{color:rgba(30,30,58,.6)}.text-dark-500\/65{color:rgba(30,30,58,.65)}.text-dark-500\/70{color:rgba(30,30,58,.7)}.text-dark-500\/75{color:rgba(30,30,58,.75)}.text-dark-500\/80{color:rgba(30,30,58,.8)}.text-dark-500\/85{color:rgba(30,30,58,.85)}.text-dark-500\/90{color:rgba(30,30,58,.9)}.text-dark-500\/95{color:rgba(30,30,58,.95)}.text-dark-600{--tw-text-opacity:1;color:rgb(24 24 48/var(--tw-text-opacity,1))}.text-dark-600\/0{color:rgba(24,24,48,0)}.text-dark-600\/10{color:rgba(24,24,48,.1)}.text-dark-600\/100{color:#181830}.text-dark-600\/15{color:rgba(24,24,48,.15)}.text-dark-600\/20{color:rgba(24,24,48,.2)}.text-dark-600\/25{color:rgba(24,24,48,.25)}.text-dark-600\/30{color:rgba(24,24,48,.3)}.text-dark-600\/35{color:rgba(24,24,48,.35)}.text-dark-600\/40{color:rgba(24,24,48,.4)}.text-dark-600\/45{color:rgba(24,24,48,.45)}.text-dark-600\/5{color:rgba(24,24,48,.05)}.text-dark-600\/50{color:rgba(24,24,48,.5)}.text-dark-600\/55{color:rgba(24,24,48,.55)}.text-dark-600\/60{color:rgba(24,24,48,.6)}.text-dark-600\/65{color:rgba(24,24,48,.65)}.text-dark-600\/70{color:rgba(24,24,48,.7)}.text-dark-600\/75{color:rgba(24,24,48,.75)}.text-dark-600\/80{color:rgba(24,24,48,.8)}.text-dark-600\/85{color:rgba(24,24,48,.85)}.text-dark-600\/90{color:rgba(24,24,48,.9)}.text-dark-600\/95{color:rgba(24,24,48,.95)}.text-dark-700{--tw-text-opacity:1;color:rgb(16 16 30/var(--tw-text-opacity,1))}.text-dark-700\/0{color:rgba(16,16,30,0)}.text-dark-700\/10{color:rgba(16,16,30,.1)}.text-dark-700\/100{color:#10101e}.text-dark-700\/15{color:rgba(16,16,30,.15)}.text-dark-700\/20{color:rgba(16,16,30,.2)}.text-dark-700\/25{color:rgba(16,16,30,.25)}.text-dark-700\/30{color:rgba(16,16,30,.3)}.text-dark-700\/35{color:rgba(16,16,30,.35)}.text-dark-700\/40{color:rgba(16,16,30,.4)}.text-dark-700\/45{color:rgba(16,16,30,.45)}.text-dark-700\/5{color:rgba(16,16,30,.05)}.text-dark-700\/50{color:rgba(16,16,30,.5)}.text-dark-700\/55{color:rgba(16,16,30,.55)}.text-dark-700\/60{color:rgba(16,16,30,.6)}.text-dark-700\/65{color:rgba(16,16,30,.65)}.text-dark-700\/70{color:rgba(16,16,30,.7)}.text-dark-700\/75{color:rgba(16,16,30,.75)}.text-dark-700\/80{color:rgba(16,16,30,.8)}.text-dark-700\/85{color:rgba(16,16,30,.85)}.text-dark-700\/90{color:rgba(16,16,30,.9)}.text-dark-700\/95{color:rgba(16,16,30,.95)}.text-dark-800{--tw-text-opacity:1;color:rgb(10 10 18/var(--tw-text-opacity,1))}.text-dark-800\/0{color:rgba(10,10,18,0)}.text-dark-800\/10{color:rgba(10,10,18,.1)}.text-dark-800\/100{color:#0a0a12}.text-dark-800\/15{color:rgba(10,10,18,.15)}.text-dark-800\/20{color:rgba(10,10,18,.2)}.text-dark-800\/25{color:rgba(10,10,18,.25)}.text-dark-800\/30{color:rgba(10,10,18,.3)}.text-dark-800\/35{color:rgba(10,10,18,.35)}.text-dark-800\/40{color:rgba(10,10,18,.4)}.text-dark-800\/45{color:rgba(10,10,18,.45)}.text-dark-800\/5{color:rgba(10,10,18,.05)}.text-dark-800\/50{color:rgba(10,10,18,.5)}.text-dark-800\/55{color:rgba(10,10,18,.55)}.text-dark-800\/60{color:rgba(10,10,18,.6)}.text-dark-800\/65{color:rgba(10,10,18,.65)}.text-dark-800\/70{color:rgba(10,10,18,.7)}.text-dark-800\/75{color:rgba(10,10,18,.75)}.text-dark-800\/80{color:rgba(10,10,18,.8)}.text-dark-800\/85{color:rgba(10,10,18,.85)}.text-dark-800\/90{color:rgba(10,10,18,.9)}.text-dark-800\/95{color:rgba(10,10,18,.95)}.text-dark-900{--tw-text-opacity:1;color:rgb(5 5 8/var(--tw-text-opacity,1))}.text-dark-900\/0{color:rgba(5,5,8,0)}.text-dark-900\/10{color:rgba(5,5,8,.1)}.text-dark-900\/100{color:#050508}.text-dark-900\/15{color:rgba(5,5,8,.15)}.text-dark-900\/20{color:rgba(5,5,8,.2)}.text-dark-900\/25{color:rgba(5,5,8,.25)}.text-dark-900\/30{color:rgba(5,5,8,.3)}.text-dark-900\/35{color:rgba(5,5,8,.35)}.text-dark-900\/40{color:rgba(5,5,8,.4)}.text-dark-900\/45{color:rgba(5,5,8,.45)}.text-dark-900\/5{color:rgba(5,5,8,.05)}.text-dark-900\/50{color:rgba(5,5,8,.5)}.text-dark-900\/55{color:rgba(5,5,8,.55)}.text-dark-900\/60{color:rgba(5,5,8,.6)}.text-dark-900\/65{color:rgba(5,5,8,.65)}.text-dark-900\/70{color:rgba(5,5,8,.7)}.text-dark-900\/75{color:rgba(5,5,8,.75)}.text-dark-900\/80{color:rgba(5,5,8,.8)}.text-dark-900\/85{color:rgba(5,5,8,.85)}.text-dark-900\/90{color:rgba(5,5,8,.9)}.text-dark-900\/95{color:rgba(5,5,8,.95)}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-fb-accent{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-fb-gold{--tw-text-opacity:1;color:rgb(232 192 64/var(--tw-text-opacity,1))}.text-fb-good{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-fb-low{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-fb-mid{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-fb-primary{--tw-text-opacity:1;color:rgb(14 165 233/var(--tw-text-opacity,1))}.text-fb-text{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.text-fb-text\/80{color:rgba(248,250,252,.8)}.text-fb-textDim{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-fb-textDim\/40{color:rgba(148,163,184,.4)}.text-fb-textDim\/60{color:rgba(148,163,184,.6)}.text-fb-textDim\/70{color:rgba(148,163,184,.7)}.text-gold{--tw-text-opacity:1;color:rgb(232 192 64/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-100{--tw-text-opacity:1;color:rgb(220 252 231/var(--tw-text-opacity,1))}.text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-50{--tw-text-opacity:1;color:rgb(240 253 244/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-green-900{--tw-text-opacity:1;color:rgb(20 83 45/var(--tw-text-opacity,1))}.text-indigo-100{--tw-text-opacity:1;color:rgb(224 231 255/var(--tw-text-opacity,1))}.text-indigo-200{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.text-indigo-300{--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity,1))}.text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-indigo-50{--tw-text-opacity:1;color:rgb(238 242 255/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.text-indigo-900{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.text-pink-100{--tw-text-opacity:1;color:rgb(252 231 243/var(--tw-text-opacity,1))}.text-pink-200{--tw-text-opacity:1;color:rgb(251 207 232/var(--tw-text-opacity,1))}.text-pink-300{--tw-text-opacity:1;color:rgb(249 168 212/var(--tw-text-opacity,1))}.text-pink-400{--tw-text-opacity:1;color:rgb(244 114 182/var(--tw-text-opacity,1))}.text-pink-50{--tw-text-opacity:1;color:rgb(253 242 248/var(--tw-text-opacity,1))}.text-pink-500{--tw-text-opacity:1;color:rgb(236 72 153/var(--tw-text-opacity,1))}.text-pink-600{--tw-text-opacity:1;color:rgb(219 39 119/var(--tw-text-opacity,1))}.text-pink-700{--tw-text-opacity:1;color:rgb(190 24 93/var(--tw-text-opacity,1))}.text-pink-800{--tw-text-opacity:1;color:rgb(157 23 77/var(--tw-text-opacity,1))}.text-pink-900{--tw-text-opacity:1;color:rgb(131 24 67/var(--tw-text-opacity,1))}.text-purple-100{--tw-text-opacity:1;color:rgb(243 232 255/var(--tw-text-opacity,1))}.text-purple-200{--tw-text-opacity:1;color:rgb(233 213 255/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-purple-50{--tw-text-opacity:1;color:rgb(250 245 255/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-purple-900{--tw-text-opacity:1;color:rgb(88 28 135/var(--tw-text-opacity,1))}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-400\/90{color:hsla(0,91%,71%,.9)}.text-red-50{--tw-text-opacity:1;color:rgb(254 242 242/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-sky-500{--tw-text-opacity:1;color:rgb(14 165 233/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-50{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-transparent{color:transparent}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/30{color:hsla(0,0%,100%,.3)}.text-yellow-100{--tw-text-opacity:1;color:rgb(254 249 195/var(--tw-text-opacity,1))}.text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.text-yellow-300{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.text-yellow-300\/80{color:rgba(253,224,71,.8)}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-50{--tw-text-opacity:1;color:rgb(254 252 232/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.overline{text-decoration-line:overline}.line-through{text-decoration-line:line-through}.placeholder-fb-textDim::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(148 163 184/var(--tw-placeholder-opacity,1))}.placeholder-fb-textDim::placeholder{--tw-placeholder-opacity:1;color:rgb(148 163 184/var(--tw-placeholder-opacity,1))}.placeholder-gray-600::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity,1))}.placeholder-gray-600::placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity,1))}.accent-accent{accent-color:#4080e0}.accent-fb-primary{accent-color:#0ea5e9}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-40{opacity:.4}.opacity-5{opacity:.05}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-\[0_0_10px_3px_rgba\(52\2c 211\2c 153\2c 0\.95\)\]{--tw-shadow:0 0 10px 3px rgba(52,211,153,.95);--tw-shadow-colored:0 0 10px 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(192\2c 132\2c 252\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(192,132,252,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(192\2c 132\2c 252\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(192,132,252,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(216\2c 180\2c 254\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(216,180,254,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(221\2c 214\2c 254\2c 0\.6\)\]{--tw-shadow:0 0 12px rgba(221,214,254,.6);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(251\2c 146\2c 60\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(251,146,60,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(251\2c 146\2c 60\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(251,146,60,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(96\2c 165\2c 250\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(96,165,250,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(96\2c 165\2c 250\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(96,165,250,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_4px_1px_rgba\(185\2c 28\2c 28\2c 0\.7\)\]{--tw-shadow:0 0 4px 1px rgba(185,28,28,.7);--tw-shadow-colored:0 0 4px 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_6px_2px_rgba\(239\2c 68\2c 68\2c 0\.8\)\]{--tw-shadow:0 0 6px 2px rgba(239,68,68,.8);--tw-shadow-colored:0 0 6px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_6px_2px_rgba\(249\2c 115\2c 22\2c 0\.85\)\]{--tw-shadow:0 0 6px 2px rgba(249,115,22,.85);--tw-shadow-colored:0 0 6px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_2px_rgba\(234\2c 179\2c 8\2c 0\.9\)\]{--tw-shadow:0 0 8px 2px rgba(234,179,8,.9);--tw-shadow-colored:0 0 8px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_2px_rgba\(52\2c 211\2c 153\2c 0\.85\)\]{--tw-shadow:0 0 8px 2px rgba(52,211,153,.85);--tw-shadow-colored:0 0 8px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(255\2c 255\2c 255\2c 0\.5\)\]{--tw-shadow:0 0 8px hsla(0,0%,100%,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(74\2c 222\2c 128\2c 0\.5\)\]{--tw-shadow:0 0 8px rgba(74,222,128,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color)}.shadow-\[0_0_8px_rgba\(74\2c 222\2c 128\2c 0\.5\)\],.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-fb-primary\/20{--tw-shadow-color:rgba(14,165,233,.2);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.\!ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)!important;--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)!important;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)!important}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-amber-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 243 199/var(--tw-ring-opacity,1))}.ring-amber-200{--tw-ring-opacity:1;--tw-ring-color:rgb(253 230 138/var(--tw-ring-opacity,1))}.ring-amber-300{--tw-ring-opacity:1;--tw-ring-color:rgb(252 211 77/var(--tw-ring-opacity,1))}.ring-amber-400{--tw-ring-opacity:1;--tw-ring-color:rgb(251 191 36/var(--tw-ring-opacity,1))}.ring-amber-50{--tw-ring-opacity:1;--tw-ring-color:rgb(255 251 235/var(--tw-ring-opacity,1))}.ring-amber-500{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.ring-amber-600{--tw-ring-opacity:1;--tw-ring-color:rgb(217 119 6/var(--tw-ring-opacity,1))}.ring-amber-700{--tw-ring-opacity:1;--tw-ring-color:rgb(180 83 9/var(--tw-ring-opacity,1))}.ring-amber-800{--tw-ring-opacity:1;--tw-ring-color:rgb(146 64 14/var(--tw-ring-opacity,1))}.ring-amber-900{--tw-ring-opacity:1;--tw-ring-color:rgb(120 53 15/var(--tw-ring-opacity,1))}.ring-blue-100{--tw-ring-opacity:1;--tw-ring-color:rgb(219 234 254/var(--tw-ring-opacity,1))}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.ring-blue-400{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.ring-blue-50{--tw-ring-opacity:1;--tw-ring-color:rgb(239 246 255/var(--tw-ring-opacity,1))}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.ring-blue-600{--tw-ring-opacity:1;--tw-ring-color:rgb(37 99 235/var(--tw-ring-opacity,1))}.ring-blue-700{--tw-ring-opacity:1;--tw-ring-color:rgb(29 78 216/var(--tw-ring-opacity,1))}.ring-blue-800{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity,1))}.ring-blue-900{--tw-ring-opacity:1;--tw-ring-color:rgb(30 58 138/var(--tw-ring-opacity,1))}.ring-cyan-500\/30{--tw-ring-color:rgba(6,182,212,.3)}.ring-fb-primary{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.ring-gray-100{--tw-ring-opacity:1;--tw-ring-color:rgb(243 244 246/var(--tw-ring-opacity,1))}.ring-gray-200{--tw-ring-opacity:1;--tw-ring-color:rgb(229 231 235/var(--tw-ring-opacity,1))}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity,1))}.ring-gray-400{--tw-ring-opacity:1;--tw-ring-color:rgb(156 163 175/var(--tw-ring-opacity,1))}.ring-gray-50{--tw-ring-opacity:1;--tw-ring-color:rgb(249 250 251/var(--tw-ring-opacity,1))}.ring-gray-500{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.ring-gray-600{--tw-ring-opacity:1;--tw-ring-color:rgb(75 85 99/var(--tw-ring-opacity,1))}.ring-gray-700{--tw-ring-opacity:1;--tw-ring-color:rgb(55 65 81/var(--tw-ring-opacity,1))}.ring-gray-800{--tw-ring-opacity:1;--tw-ring-color:rgb(31 41 55/var(--tw-ring-opacity,1))}.ring-gray-900{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.ring-green-100{--tw-ring-opacity:1;--tw-ring-color:rgb(220 252 231/var(--tw-ring-opacity,1))}.ring-green-200{--tw-ring-opacity:1;--tw-ring-color:rgb(187 247 208/var(--tw-ring-opacity,1))}.ring-green-300{--tw-ring-opacity:1;--tw-ring-color:rgb(134 239 172/var(--tw-ring-opacity,1))}.ring-green-400{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.ring-green-50{--tw-ring-opacity:1;--tw-ring-color:rgb(240 253 244/var(--tw-ring-opacity,1))}.ring-green-500{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.ring-green-600{--tw-ring-opacity:1;--tw-ring-color:rgb(22 163 74/var(--tw-ring-opacity,1))}.ring-green-700{--tw-ring-opacity:1;--tw-ring-color:rgb(21 128 61/var(--tw-ring-opacity,1))}.ring-green-800{--tw-ring-opacity:1;--tw-ring-color:rgb(22 101 52/var(--tw-ring-opacity,1))}.ring-green-900{--tw-ring-opacity:1;--tw-ring-color:rgb(20 83 45/var(--tw-ring-opacity,1))}.ring-indigo-100{--tw-ring-opacity:1;--tw-ring-color:rgb(224 231 255/var(--tw-ring-opacity,1))}.ring-indigo-200{--tw-ring-opacity:1;--tw-ring-color:rgb(199 210 254/var(--tw-ring-opacity,1))}.ring-indigo-300{--tw-ring-opacity:1;--tw-ring-color:rgb(165 180 252/var(--tw-ring-opacity,1))}.ring-indigo-400{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.ring-indigo-50{--tw-ring-opacity:1;--tw-ring-color:rgb(238 242 255/var(--tw-ring-opacity,1))}.ring-indigo-500{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.ring-indigo-600{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity,1))}.ring-indigo-700{--tw-ring-opacity:1;--tw-ring-color:rgb(67 56 202/var(--tw-ring-opacity,1))}.ring-indigo-800{--tw-ring-opacity:1;--tw-ring-color:rgb(55 48 163/var(--tw-ring-opacity,1))}.ring-indigo-900{--tw-ring-opacity:1;--tw-ring-color:rgb(49 46 129/var(--tw-ring-opacity,1))}.ring-pink-100{--tw-ring-opacity:1;--tw-ring-color:rgb(252 231 243/var(--tw-ring-opacity,1))}.ring-pink-200{--tw-ring-opacity:1;--tw-ring-color:rgb(251 207 232/var(--tw-ring-opacity,1))}.ring-pink-300{--tw-ring-opacity:1;--tw-ring-color:rgb(249 168 212/var(--tw-ring-opacity,1))}.ring-pink-400{--tw-ring-opacity:1;--tw-ring-color:rgb(244 114 182/var(--tw-ring-opacity,1))}.ring-pink-50{--tw-ring-opacity:1;--tw-ring-color:rgb(253 242 248/var(--tw-ring-opacity,1))}.ring-pink-500{--tw-ring-opacity:1;--tw-ring-color:rgb(236 72 153/var(--tw-ring-opacity,1))}.ring-pink-600{--tw-ring-opacity:1;--tw-ring-color:rgb(219 39 119/var(--tw-ring-opacity,1))}.ring-pink-700{--tw-ring-opacity:1;--tw-ring-color:rgb(190 24 93/var(--tw-ring-opacity,1))}.ring-pink-800{--tw-ring-opacity:1;--tw-ring-color:rgb(157 23 77/var(--tw-ring-opacity,1))}.ring-pink-900{--tw-ring-opacity:1;--tw-ring-color:rgb(131 24 67/var(--tw-ring-opacity,1))}.ring-purple-100{--tw-ring-opacity:1;--tw-ring-color:rgb(243 232 255/var(--tw-ring-opacity,1))}.ring-purple-200{--tw-ring-opacity:1;--tw-ring-color:rgb(233 213 255/var(--tw-ring-opacity,1))}.ring-purple-300{--tw-ring-opacity:1;--tw-ring-color:rgb(216 180 254/var(--tw-ring-opacity,1))}.ring-purple-400{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.ring-purple-50{--tw-ring-opacity:1;--tw-ring-color:rgb(250 245 255/var(--tw-ring-opacity,1))}.ring-purple-500{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.ring-purple-600{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity,1))}.ring-purple-700{--tw-ring-opacity:1;--tw-ring-color:rgb(126 34 206/var(--tw-ring-opacity,1))}.ring-purple-800{--tw-ring-opacity:1;--tw-ring-color:rgb(107 33 168/var(--tw-ring-opacity,1))}.ring-purple-900{--tw-ring-opacity:1;--tw-ring-color:rgb(88 28 135/var(--tw-ring-opacity,1))}.ring-red-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 226 226/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-300{--tw-ring-opacity:1;--tw-ring-color:rgb(252 165 165/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.ring-red-50{--tw-ring-opacity:1;--tw-ring-color:rgb(254 242 242/var(--tw-ring-opacity,1))}.ring-red-500{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.ring-red-600{--tw-ring-opacity:1;--tw-ring-color:rgb(220 38 38/var(--tw-ring-opacity,1))}.ring-red-700{--tw-ring-opacity:1;--tw-ring-color:rgb(185 28 28/var(--tw-ring-opacity,1))}.ring-red-800{--tw-ring-opacity:1;--tw-ring-color:rgb(153 27 27/var(--tw-ring-opacity,1))}.ring-red-900{--tw-ring-opacity:1;--tw-ring-color:rgb(127 29 29/var(--tw-ring-opacity,1))}.ring-slate-100{--tw-ring-opacity:1;--tw-ring-color:rgb(241 245 249/var(--tw-ring-opacity,1))}.ring-slate-200{--tw-ring-opacity:1;--tw-ring-color:rgb(226 232 240/var(--tw-ring-opacity,1))}.ring-slate-300{--tw-ring-opacity:1;--tw-ring-color:rgb(203 213 225/var(--tw-ring-opacity,1))}.ring-slate-400{--tw-ring-opacity:1;--tw-ring-color:rgb(148 163 184/var(--tw-ring-opacity,1))}.ring-slate-50{--tw-ring-opacity:1;--tw-ring-color:rgb(248 250 252/var(--tw-ring-opacity,1))}.ring-slate-500{--tw-ring-opacity:1;--tw-ring-color:rgb(100 116 139/var(--tw-ring-opacity,1))}.ring-slate-600{--tw-ring-opacity:1;--tw-ring-color:rgb(71 85 105/var(--tw-ring-opacity,1))}.ring-slate-700{--tw-ring-opacity:1;--tw-ring-color:rgb(51 65 85/var(--tw-ring-opacity,1))}.ring-slate-800{--tw-ring-opacity:1;--tw-ring-color:rgb(30 41 59/var(--tw-ring-opacity,1))}.ring-slate-900{--tw-ring-opacity:1;--tw-ring-color:rgb(15 23 42/var(--tw-ring-opacity,1))}.ring-yellow-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 249 195/var(--tw-ring-opacity,1))}.ring-yellow-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 240 138/var(--tw-ring-opacity,1))}.ring-yellow-300{--tw-ring-opacity:1;--tw-ring-color:rgb(253 224 71/var(--tw-ring-opacity,1))}.ring-yellow-400{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.ring-yellow-50{--tw-ring-opacity:1;--tw-ring-color:rgb(254 252 232/var(--tw-ring-opacity,1))}.ring-yellow-500{--tw-ring-opacity:1;--tw-ring-color:rgb(234 179 8/var(--tw-ring-opacity,1))}.ring-yellow-600{--tw-ring-opacity:1;--tw-ring-color:rgb(202 138 4/var(--tw-ring-opacity,1))}.ring-yellow-700{--tw-ring-opacity:1;--tw-ring-color:rgb(161 98 7/var(--tw-ring-opacity,1))}.ring-yellow-800{--tw-ring-opacity:1;--tw-ring-color:rgb(133 77 14/var(--tw-ring-opacity,1))}.ring-yellow-900{--tw-ring-opacity:1;--tw-ring-color:rgb(113 63 18/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.invert{--tw-invert:invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-blur-lg{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-4:after{content:var(--tw-content);height:1rem}.after\:w-4:after{content:var(--tw-content);width:1rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.first\:border-t-0:first-child{border-top-width:0}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-width:0}.hover\:border-accent\/40:hover{border-color:rgba(64,128,224,.4)}.hover\:border-accent\/50:hover{border-color:rgba(64,128,224,.5)}.hover\:border-fb-border:hover{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.hover\:border-fb-primary:hover{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.hover\:border-fb-primary\/40:hover{border-color:rgba(14,165,233,.4)}.hover\:border-fb-primary\/50:hover{border-color:rgba(14,165,233,.5)}.hover\:border-gray-600:hover{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.hover\:border-red-700:hover{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.hover\:border-white:hover{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.hover\:bg-accent-light:hover{--tw-bg-opacity:1;background-color:rgb(96 160 255/var(--tw-bg-opacity,1))}.hover\:bg-accent\/30:hover{background-color:rgba(64,128,224,.3)}.hover\:bg-black\/70:hover{background-color:rgba(0,0,0,.7)}.hover\:bg-black\/75:hover{background-color:rgba(0,0,0,.75)}.hover\:bg-dark-500:hover{--tw-bg-opacity:1;background-color:rgb(30 30 58/var(--tw-bg-opacity,1))}.hover\:bg-dark-700:hover{--tw-bg-opacity:1;background-color:rgb(16 16 30/var(--tw-bg-opacity,1))}.hover\:bg-dark-700\/70:hover{background-color:rgba(16,16,30,.7)}.hover\:bg-dark-800\/50:hover{background-color:rgba(10,10,18,.5)}.hover\:bg-dark-900:hover{--tw-bg-opacity:1;background-color:rgb(5 5 8/var(--tw-bg-opacity,1))}.hover\:bg-fb-border:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:bg-fb-card:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:bg-fb-card\/50:hover{background-color:rgba(30,41,59,.5)}.hover\:bg-fb-card\/60:hover{background-color:rgba(30,41,59,.6)}.hover\:bg-fb-card\/70:hover{background-color:rgba(30,41,59,.7)}.hover\:bg-fb-primary:hover{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.hover\:bg-fb-primaryHi:hover{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.hover\:bg-gold\/20:hover{background-color:rgba(232,192,64,.2)}.hover\:bg-green-900\/50:hover{background-color:rgba(20,83,45,.5)}.hover\:bg-purple-900\/60:hover{background-color:rgba(88,28,135,.6)}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-500\/10:hover{background-color:rgba(239,68,68,.1)}.hover\:bg-red-900\/30:hover{background-color:rgba(127,29,29,.3)}.hover\:bg-red-900\/50:hover{background-color:rgba(127,29,29,.5)}.hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.hover\:text-accent:hover{--tw-text-opacity:1;color:rgb(64 128 224/var(--tw-text-opacity,1))}.hover\:text-accent-light:hover{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.hover\:text-fb-accent:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-fb-primaryHi:hover{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.hover\:text-fb-text:hover{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.hover\:text-gray-400:hover{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.hover\:text-red-100:hover{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.hover\:text-red-200:hover{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.hover\:ring-1:hover,.hover\:ring-2:hover{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.hover\:ring-fb-primary\/40:hover{--tw-ring-color:rgba(14,165,233,.4)}.hover\:ring-fb-primary\/50:hover{--tw-ring-color:rgba(14,165,233,.5)}.focus\:border-accent:focus{--tw-border-opacity:1;border-color:rgb(64 128 224/var(--tw-border-opacity,1))}.focus\:border-accent\/50:focus{border-color:rgba(64,128,224,.5)}.focus\:border-fb-primary:focus{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-accent\/30:focus{--tw-ring-color:rgba(64,128,224,.3)}.focus\:ring-accent\/40:focus{--tw-ring-color:rgba(64,128,224,.4)}.focus\:ring-accent\/60:focus{--tw-ring-color:rgba(64,128,224,.6)}.focus\:ring-fb-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.focus\:ring-gray-500\/40:focus{--tw-ring-color:hsla(220,9%,46%,.4)}.focus\:ring-red-400\/60:focus{--tw-ring-color:hsla(0,91%,71%,.6)}.focus\:ring-sky-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group[open] .group-open\:rotate-180{--tw-rotate:180deg}.group:hover .group-hover\:scale-105,.group[open] .group-open\:rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:text-accent-light{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-fb-text{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}.group:hover .group-hover\:opacity-70{opacity:.7}.peer:checked~.peer-checked\:bg-accent{--tw-bg-opacity:1;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:outline-none{outline:2px solid transparent;outline-offset:2px}@media (min-width:640px){.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:w-96{width:24rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-start{justify-content:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:-mx-8{margin-left:-2rem;margin-right:-2rem}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-60{width:15rem}.md\:w-auto{width:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:p-8{padding:2rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:w-96{width:24rem}.lg\:max-w-96{max-width:24rem}.lg\:flex-none{flex:none}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(9rem\2c 0\.9fr\)_minmax\(12rem\2c 1fr\)_4rem_minmax\(12rem\2c 1fr\)\]{grid-template-columns:minmax(9rem,.9fr) minmax(12rem,1fr) 4rem minmax(12rem,1fr)}.lg\:items-center{align-items:center}.lg\:items-stretch{align-items:stretch}.lg\:justify-end{justify-content:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:gap-16{gap:4rem}}@media (min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:gap-24{gap:6rem}} \ No newline at end of file +/*! tailwindcss v3.4.19 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.invisible{visibility:hidden}.collapse{visibility:collapse}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:0}.inset-y-0{top:0;bottom:0}.-left-\[1\.4375rem\]{left:-1.4375rem}.-right-\[1\.4375rem\]{right:-1.4375rem}.bottom-0{bottom:0}.bottom-1{bottom:.25rem}.bottom-1\.5{bottom:.375rem}.bottom-4{bottom:1rem}.bottom-5{bottom:1.25rem}.bottom-6{bottom:1.5rem}.bottom-\[30\%\]{bottom:30%}.bottom-full{bottom:100%}.left-0{left:0}.left-0\.5{left:.125rem}.left-1\.5{left:.375rem}.left-1\/2{left:50%}.left-2{left:.5rem}.left-2\.5{left:.625rem}.left-3{left:.75rem}.left-9{left:2.25rem}.left-\[30\%\]{left:30%}.right-0{right:0}.right-0\.5{right:.125rem}.right-1\.5{right:.375rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.right-5{right:1.25rem}.right-\[30\%\]{right:30%}.top-0{top:0}.top-1{top:.25rem}.top-1\.5{top:.375rem}.top-1\/2{top:50%}.top-10{top:2.5rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-\[30\%\]{top:30%}.top-full{top:100%}.z-10{z-index:10}.z-20{z-index:20}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.z-\[1000\]{z-index:1000}.z-\[1001\]{z-index:1001}.z-\[100\]{z-index:100}.z-\[120\]{z-index:120}.z-\[200\]{z-index:200}.z-\[210\]{z-index:210}.z-\[250\]{z-index:250}.z-\[300\]{z-index:300}.z-\[60\]{z-index:60}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.m-1{margin:.25rem}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.-mx-6{margin-left:-1.5rem;margin-right:-1.5rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-1{margin-top:.25rem;margin-bottom:.25rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-3{margin-left:.75rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-10{margin-top:2.5rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-auto{margin-top:auto}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.\!block{display:block!important}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.\!grid{display:grid!important}.grid{display:grid}.contents{display:contents}.hidden{display:none}.aspect-\[3\/1\]{aspect-ratio:3/1}.aspect-square{aspect-ratio:1/1}.aspect-video{aspect-ratio:16/9}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-1\/2{height:50%}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-2\/3{height:66.666667%}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-\[3px\]{height:3px}.h-\[92px\]{height:92px}.h-\[calc\(50\%-1\.5px\)\]{height:calc(50% - 1.5px)}.h-\[calc\(50\%-2\.5px\)\]{height:calc(50% - 2.5px)}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-64{max-height:16rem}.max-h-72{max-height:18rem}.max-h-96{max-height:24rem}.max-h-\[80vh\]{max-height:80vh}.min-h-5{min-height:1.25rem}.min-h-6{min-height:1.5rem}.min-h-\[18rem\]{min-height:18rem}.min-h-\[2\.5em\]{min-height:2.5em}.min-h-\[480px\]{min-height:480px}.w-0\.5{width:.125rem}.w-1{width:.25rem}.w-1\/2{width:50%}.w-10{width:2.5rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-2\/3{width:66.666667%}.w-20{width:5rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-64{width:16rem}.w-7{width:1.75rem}.w-72{width:18rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[96px\]{width:96px}.w-\[calc\(50\%-1px\)\]{width:calc(50% - 1px)}.w-\[calc\(50\%-2px\)\]{width:calc(50% - 2px)}.w-full{width:100%}.min-w-0{min-width:0}.min-w-40{min-width:10rem}.min-w-\[1\.25rem\]{min-width:1.25rem}.min-w-\[1\.75rem\]{min-width:1.75rem}.min-w-\[10rem\]{min-width:10rem}.min-w-\[180px\]{min-width:180px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-7xl{max-width:80rem}.max-w-\[11rem\]{max-width:11rem}.max-w-\[130px\]{max-width:130px}.max-w-\[160px\]{max-width:160px}.max-w-\[210px\]{max-width:210px}.max-w-\[5\.5rem\]{max-width:5.5rem}.max-w-\[90\%\]{max-width:90%}.max-w-full{max-width:100%}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.-translate-x-1\/2{--tw-translate-x:-50%}.-translate-x-1\/2,.-translate-y-1\/2{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y:-50%}.translate-x-full{--tw-translate-x:100%}.-rotate-12,.translate-x-full{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-12{--tw-rotate:-12deg}.rotate-0{--tw-rotate:0deg}.rotate-0,.rotate-12{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-12{--tw-rotate:12deg}.rotate-45{--tw-rotate:45deg}.rotate-45,.rotate-90{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate:90deg}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-default{cursor:default}.cursor-grab{cursor:grab}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-rows-2{grid-template-rows:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-start{justify-content:flex-start}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-\[3px\]{gap:3px}.gap-px{gap:1px}.space-y-0>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(0px*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(0px*var(--tw-space-y-reverse))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.125rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem*var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem*var(--tw-space-y-reverse))}.space-y-10>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(2.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(2.5rem*var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem*var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.75rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem*var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem*var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.25rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem*var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(1.5rem*(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem*var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse:0;border-top-width:calc(1px*(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px*var(--tw-divide-y-reverse))}.divide-fb-border\/30>:not([hidden])~:not([hidden]){border-color:rgba(51,65,85,.3)}.self-start{align-self:flex-start}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.scroll-smooth{scroll-behavior:smooth}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-wrap{text-wrap:wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-b-lg{border-bottom-right-radius:.5rem;border-bottom-left-radius:.5rem}.rounded-t-lg{border-top-left-radius:.5rem;border-top-right-radius:.5rem}.rounded-bl-md{border-bottom-left-radius:.375rem}.rounded-br-md{border-bottom-right-radius:.375rem}.rounded-tl-md{border-top-left-radius:.375rem}.rounded-tr-md{border-top-right-radius:.375rem}.border{border-width:1px}.border-0{border-width:0}.border-2{border-width:2px}.border-x{border-left-width:1px;border-right-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-t-0{border-top-width:0}.border-dashed{border-style:dashed}.border-none{border-style:none}.border-accent{--tw-border-opacity:1;border-color:rgb(64 128 224/var(--tw-border-opacity,1))}.border-accent-dark{--tw-border-opacity:1;border-color:rgb(32 96 176/var(--tw-border-opacity,1))}.border-accent-dark\/0{border-color:rgba(32,96,176,0)}.border-accent-dark\/10{border-color:rgba(32,96,176,.1)}.border-accent-dark\/100{border-color:#2060b0}.border-accent-dark\/15{border-color:rgba(32,96,176,.15)}.border-accent-dark\/20{border-color:rgba(32,96,176,.2)}.border-accent-dark\/25{border-color:rgba(32,96,176,.25)}.border-accent-dark\/30{border-color:rgba(32,96,176,.3)}.border-accent-dark\/35{border-color:rgba(32,96,176,.35)}.border-accent-dark\/40{border-color:rgba(32,96,176,.4)}.border-accent-dark\/45{border-color:rgba(32,96,176,.45)}.border-accent-dark\/5{border-color:rgba(32,96,176,.05)}.border-accent-dark\/50{border-color:rgba(32,96,176,.5)}.border-accent-dark\/55{border-color:rgba(32,96,176,.55)}.border-accent-dark\/60{border-color:rgba(32,96,176,.6)}.border-accent-dark\/65{border-color:rgba(32,96,176,.65)}.border-accent-dark\/70{border-color:rgba(32,96,176,.7)}.border-accent-dark\/75{border-color:rgba(32,96,176,.75)}.border-accent-dark\/80{border-color:rgba(32,96,176,.8)}.border-accent-dark\/85{border-color:rgba(32,96,176,.85)}.border-accent-dark\/90{border-color:rgba(32,96,176,.9)}.border-accent-dark\/95{border-color:rgba(32,96,176,.95)}.border-accent-light{--tw-border-opacity:1;border-color:rgb(96 160 255/var(--tw-border-opacity,1))}.border-accent-light\/0{border-color:rgba(96,160,255,0)}.border-accent-light\/10{border-color:rgba(96,160,255,.1)}.border-accent-light\/100{border-color:#60a0ff}.border-accent-light\/15{border-color:rgba(96,160,255,.15)}.border-accent-light\/20{border-color:rgba(96,160,255,.2)}.border-accent-light\/25{border-color:rgba(96,160,255,.25)}.border-accent-light\/30{border-color:rgba(96,160,255,.3)}.border-accent-light\/35{border-color:rgba(96,160,255,.35)}.border-accent-light\/40{border-color:rgba(96,160,255,.4)}.border-accent-light\/45{border-color:rgba(96,160,255,.45)}.border-accent-light\/5{border-color:rgba(96,160,255,.05)}.border-accent-light\/50{border-color:rgba(96,160,255,.5)}.border-accent-light\/55{border-color:rgba(96,160,255,.55)}.border-accent-light\/60{border-color:rgba(96,160,255,.6)}.border-accent-light\/65{border-color:rgba(96,160,255,.65)}.border-accent-light\/70{border-color:rgba(96,160,255,.7)}.border-accent-light\/75{border-color:rgba(96,160,255,.75)}.border-accent-light\/80{border-color:rgba(96,160,255,.8)}.border-accent-light\/85{border-color:rgba(96,160,255,.85)}.border-accent-light\/90{border-color:rgba(96,160,255,.9)}.border-accent-light\/95{border-color:rgba(96,160,255,.95)}.border-accent\/20{border-color:rgba(64,128,224,.2)}.border-accent\/40{border-color:rgba(64,128,224,.4)}.border-amber-100{--tw-border-opacity:1;border-color:rgb(254 243 199/var(--tw-border-opacity,1))}.border-amber-200{--tw-border-opacity:1;border-color:rgb(253 230 138/var(--tw-border-opacity,1))}.border-amber-300{--tw-border-opacity:1;border-color:rgb(252 211 77/var(--tw-border-opacity,1))}.border-amber-400{--tw-border-opacity:1;border-color:rgb(251 191 36/var(--tw-border-opacity,1))}.border-amber-50{--tw-border-opacity:1;border-color:rgb(255 251 235/var(--tw-border-opacity,1))}.border-amber-500{--tw-border-opacity:1;border-color:rgb(245 158 11/var(--tw-border-opacity,1))}.border-amber-500\/30{border-color:rgba(245,158,11,.3)}.border-amber-600{--tw-border-opacity:1;border-color:rgb(217 119 6/var(--tw-border-opacity,1))}.border-amber-700{--tw-border-opacity:1;border-color:rgb(180 83 9/var(--tw-border-opacity,1))}.border-amber-700\/60{border-color:rgba(180,83,9,.6)}.border-amber-800{--tw-border-opacity:1;border-color:rgb(146 64 14/var(--tw-border-opacity,1))}.border-amber-800\/60{border-color:rgba(146,64,14,.6)}.border-amber-900{--tw-border-opacity:1;border-color:rgb(120 53 15/var(--tw-border-opacity,1))}.border-blue-100{--tw-border-opacity:1;border-color:rgb(219 234 254/var(--tw-border-opacity,1))}.border-blue-200{--tw-border-opacity:1;border-color:rgb(191 219 254/var(--tw-border-opacity,1))}.border-blue-300{--tw-border-opacity:1;border-color:rgb(147 197 253/var(--tw-border-opacity,1))}.border-blue-400{--tw-border-opacity:1;border-color:rgb(96 165 250/var(--tw-border-opacity,1))}.border-blue-50{--tw-border-opacity:1;border-color:rgb(239 246 255/var(--tw-border-opacity,1))}.border-blue-500{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.border-blue-600{--tw-border-opacity:1;border-color:rgb(37 99 235/var(--tw-border-opacity,1))}.border-blue-700{--tw-border-opacity:1;border-color:rgb(29 78 216/var(--tw-border-opacity,1))}.border-blue-700\/60{border-color:rgba(29,78,216,.6)}.border-blue-800{--tw-border-opacity:1;border-color:rgb(30 64 175/var(--tw-border-opacity,1))}.border-blue-900{--tw-border-opacity:1;border-color:rgb(30 58 138/var(--tw-border-opacity,1))}.border-dark-500{--tw-border-opacity:1;border-color:rgb(30 30 58/var(--tw-border-opacity,1))}.border-dark-500\/0{border-color:rgba(30,30,58,0)}.border-dark-500\/10{border-color:rgba(30,30,58,.1)}.border-dark-500\/100{border-color:#1e1e3a}.border-dark-500\/15{border-color:rgba(30,30,58,.15)}.border-dark-500\/20{border-color:rgba(30,30,58,.2)}.border-dark-500\/25{border-color:rgba(30,30,58,.25)}.border-dark-500\/30{border-color:rgba(30,30,58,.3)}.border-dark-500\/35{border-color:rgba(30,30,58,.35)}.border-dark-500\/40{border-color:rgba(30,30,58,.4)}.border-dark-500\/45{border-color:rgba(30,30,58,.45)}.border-dark-500\/5{border-color:rgba(30,30,58,.05)}.border-dark-500\/50{border-color:rgba(30,30,58,.5)}.border-dark-500\/55{border-color:rgba(30,30,58,.55)}.border-dark-500\/60{border-color:rgba(30,30,58,.6)}.border-dark-500\/65{border-color:rgba(30,30,58,.65)}.border-dark-500\/70{border-color:rgba(30,30,58,.7)}.border-dark-500\/75{border-color:rgba(30,30,58,.75)}.border-dark-500\/80{border-color:rgba(30,30,58,.8)}.border-dark-500\/85{border-color:rgba(30,30,58,.85)}.border-dark-500\/90{border-color:rgba(30,30,58,.9)}.border-dark-500\/95{border-color:rgba(30,30,58,.95)}.border-dark-600{--tw-border-opacity:1;border-color:rgb(24 24 48/var(--tw-border-opacity,1))}.border-dark-600\/0{border-color:rgba(24,24,48,0)}.border-dark-600\/10{border-color:rgba(24,24,48,.1)}.border-dark-600\/100{border-color:#181830}.border-dark-600\/15{border-color:rgba(24,24,48,.15)}.border-dark-600\/20{border-color:rgba(24,24,48,.2)}.border-dark-600\/25{border-color:rgba(24,24,48,.25)}.border-dark-600\/30{border-color:rgba(24,24,48,.3)}.border-dark-600\/35{border-color:rgba(24,24,48,.35)}.border-dark-600\/40{border-color:rgba(24,24,48,.4)}.border-dark-600\/45{border-color:rgba(24,24,48,.45)}.border-dark-600\/5{border-color:rgba(24,24,48,.05)}.border-dark-600\/50{border-color:rgba(24,24,48,.5)}.border-dark-600\/55{border-color:rgba(24,24,48,.55)}.border-dark-600\/60{border-color:rgba(24,24,48,.6)}.border-dark-600\/65{border-color:rgba(24,24,48,.65)}.border-dark-600\/70{border-color:rgba(24,24,48,.7)}.border-dark-600\/75{border-color:rgba(24,24,48,.75)}.border-dark-600\/80{border-color:rgba(24,24,48,.8)}.border-dark-600\/85{border-color:rgba(24,24,48,.85)}.border-dark-600\/90{border-color:rgba(24,24,48,.9)}.border-dark-600\/95{border-color:rgba(24,24,48,.95)}.border-dark-700{--tw-border-opacity:1;border-color:rgb(16 16 30/var(--tw-border-opacity,1))}.border-dark-700\/0{border-color:rgba(16,16,30,0)}.border-dark-700\/10{border-color:rgba(16,16,30,.1)}.border-dark-700\/100{border-color:#10101e}.border-dark-700\/15{border-color:rgba(16,16,30,.15)}.border-dark-700\/20{border-color:rgba(16,16,30,.2)}.border-dark-700\/25{border-color:rgba(16,16,30,.25)}.border-dark-700\/30{border-color:rgba(16,16,30,.3)}.border-dark-700\/35{border-color:rgba(16,16,30,.35)}.border-dark-700\/40{border-color:rgba(16,16,30,.4)}.border-dark-700\/45{border-color:rgba(16,16,30,.45)}.border-dark-700\/5{border-color:rgba(16,16,30,.05)}.border-dark-700\/50{border-color:rgba(16,16,30,.5)}.border-dark-700\/55{border-color:rgba(16,16,30,.55)}.border-dark-700\/60{border-color:rgba(16,16,30,.6)}.border-dark-700\/65{border-color:rgba(16,16,30,.65)}.border-dark-700\/70{border-color:rgba(16,16,30,.7)}.border-dark-700\/75{border-color:rgba(16,16,30,.75)}.border-dark-700\/80{border-color:rgba(16,16,30,.8)}.border-dark-700\/85{border-color:rgba(16,16,30,.85)}.border-dark-700\/90{border-color:rgba(16,16,30,.9)}.border-dark-700\/95{border-color:rgba(16,16,30,.95)}.border-dark-800{--tw-border-opacity:1;border-color:rgb(10 10 18/var(--tw-border-opacity,1))}.border-dark-800\/0{border-color:rgba(10,10,18,0)}.border-dark-800\/10{border-color:rgba(10,10,18,.1)}.border-dark-800\/100{border-color:#0a0a12}.border-dark-800\/15{border-color:rgba(10,10,18,.15)}.border-dark-800\/20{border-color:rgba(10,10,18,.2)}.border-dark-800\/25{border-color:rgba(10,10,18,.25)}.border-dark-800\/30{border-color:rgba(10,10,18,.3)}.border-dark-800\/35{border-color:rgba(10,10,18,.35)}.border-dark-800\/40{border-color:rgba(10,10,18,.4)}.border-dark-800\/45{border-color:rgba(10,10,18,.45)}.border-dark-800\/5{border-color:rgba(10,10,18,.05)}.border-dark-800\/50{border-color:rgba(10,10,18,.5)}.border-dark-800\/55{border-color:rgba(10,10,18,.55)}.border-dark-800\/60{border-color:rgba(10,10,18,.6)}.border-dark-800\/65{border-color:rgba(10,10,18,.65)}.border-dark-800\/70{border-color:rgba(10,10,18,.7)}.border-dark-800\/75{border-color:rgba(10,10,18,.75)}.border-dark-800\/80{border-color:rgba(10,10,18,.8)}.border-dark-800\/85{border-color:rgba(10,10,18,.85)}.border-dark-800\/90{border-color:rgba(10,10,18,.9)}.border-dark-800\/95{border-color:rgba(10,10,18,.95)}.border-dark-900{--tw-border-opacity:1;border-color:rgb(5 5 8/var(--tw-border-opacity,1))}.border-dark-900\/0{border-color:rgba(5,5,8,0)}.border-dark-900\/10{border-color:rgba(5,5,8,.1)}.border-dark-900\/100{border-color:#050508}.border-dark-900\/15{border-color:rgba(5,5,8,.15)}.border-dark-900\/20{border-color:rgba(5,5,8,.2)}.border-dark-900\/25{border-color:rgba(5,5,8,.25)}.border-dark-900\/30{border-color:rgba(5,5,8,.3)}.border-dark-900\/35{border-color:rgba(5,5,8,.35)}.border-dark-900\/40{border-color:rgba(5,5,8,.4)}.border-dark-900\/45{border-color:rgba(5,5,8,.45)}.border-dark-900\/5{border-color:rgba(5,5,8,.05)}.border-dark-900\/50{border-color:rgba(5,5,8,.5)}.border-dark-900\/55{border-color:rgba(5,5,8,.55)}.border-dark-900\/60{border-color:rgba(5,5,8,.6)}.border-dark-900\/65{border-color:rgba(5,5,8,.65)}.border-dark-900\/70{border-color:rgba(5,5,8,.7)}.border-dark-900\/75{border-color:rgba(5,5,8,.75)}.border-dark-900\/80{border-color:rgba(5,5,8,.8)}.border-dark-900\/85{border-color:rgba(5,5,8,.85)}.border-dark-900\/90{border-color:rgba(5,5,8,.9)}.border-dark-900\/95{border-color:rgba(5,5,8,.95)}.border-emerald-700\/60{border-color:rgba(4,120,87,.6)}.border-emerald-800\/60{border-color:rgba(6,95,70,.6)}.border-fb-border{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-fb-border\/30{border-color:rgba(51,65,85,.3)}.border-fb-border\/40{border-color:rgba(51,65,85,.4)}.border-fb-border\/50{border-color:rgba(51,65,85,.5)}.border-fb-border\/60{border-color:rgba(51,65,85,.6)}.border-fb-border\/70{border-color:rgba(51,65,85,.7)}.border-fb-good\/40{border-color:rgba(34,197,94,.4)}.border-fb-good\/50{border-color:rgba(34,197,94,.5)}.border-fb-low\/40{border-color:rgba(239,68,68,.4)}.border-fb-primary{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.border-fb-primary\/40{border-color:rgba(14,165,233,.4)}.border-fb-primary\/50{border-color:rgba(14,165,233,.5)}.border-fb-textDim{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-gold{--tw-border-opacity:1;border-color:rgb(232 192 64/var(--tw-border-opacity,1))}.border-gold\/20{border-color:rgba(232,192,64,.2)}.border-gold\/40{border-color:rgba(232,192,64,.4)}.border-gold\/50{border-color:rgba(232,192,64,.5)}.border-gray-100{--tw-border-opacity:1;border-color:rgb(243 244 246/var(--tw-border-opacity,1))}.border-gray-200{--tw-border-opacity:1;border-color:rgb(229 231 235/var(--tw-border-opacity,1))}.border-gray-300{--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity,1))}.border-gray-50{--tw-border-opacity:1;border-color:rgb(249 250 251/var(--tw-border-opacity,1))}.border-gray-500{--tw-border-opacity:1;border-color:rgb(107 114 128/var(--tw-border-opacity,1))}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81/var(--tw-border-opacity,1))}.border-gray-700\/60{border-color:rgba(55,65,81,.6)}.border-gray-800{--tw-border-opacity:1;border-color:rgb(31 41 55/var(--tw-border-opacity,1))}.border-gray-800\/20{border-color:rgba(31,41,55,.2)}.border-gray-800\/50{border-color:rgba(31,41,55,.5)}.border-gray-800\/70{border-color:rgba(31,41,55,.7)}.border-gray-900{--tw-border-opacity:1;border-color:rgb(17 24 39/var(--tw-border-opacity,1))}.border-green-100{--tw-border-opacity:1;border-color:rgb(220 252 231/var(--tw-border-opacity,1))}.border-green-200{--tw-border-opacity:1;border-color:rgb(187 247 208/var(--tw-border-opacity,1))}.border-green-300{--tw-border-opacity:1;border-color:rgb(134 239 172/var(--tw-border-opacity,1))}.border-green-400{--tw-border-opacity:1;border-color:rgb(74 222 128/var(--tw-border-opacity,1))}.border-green-50{--tw-border-opacity:1;border-color:rgb(240 253 244/var(--tw-border-opacity,1))}.border-green-500{--tw-border-opacity:1;border-color:rgb(34 197 94/var(--tw-border-opacity,1))}.border-green-600{--tw-border-opacity:1;border-color:rgb(22 163 74/var(--tw-border-opacity,1))}.border-green-700{--tw-border-opacity:1;border-color:rgb(21 128 61/var(--tw-border-opacity,1))}.border-green-800{--tw-border-opacity:1;border-color:rgb(22 101 52/var(--tw-border-opacity,1))}.border-green-900{--tw-border-opacity:1;border-color:rgb(20 83 45/var(--tw-border-opacity,1))}.border-indigo-100{--tw-border-opacity:1;border-color:rgb(224 231 255/var(--tw-border-opacity,1))}.border-indigo-200{--tw-border-opacity:1;border-color:rgb(199 210 254/var(--tw-border-opacity,1))}.border-indigo-300{--tw-border-opacity:1;border-color:rgb(165 180 252/var(--tw-border-opacity,1))}.border-indigo-400{--tw-border-opacity:1;border-color:rgb(129 140 248/var(--tw-border-opacity,1))}.border-indigo-50{--tw-border-opacity:1;border-color:rgb(238 242 255/var(--tw-border-opacity,1))}.border-indigo-500{--tw-border-opacity:1;border-color:rgb(99 102 241/var(--tw-border-opacity,1))}.border-indigo-600{--tw-border-opacity:1;border-color:rgb(79 70 229/var(--tw-border-opacity,1))}.border-indigo-700{--tw-border-opacity:1;border-color:rgb(67 56 202/var(--tw-border-opacity,1))}.border-indigo-800{--tw-border-opacity:1;border-color:rgb(55 48 163/var(--tw-border-opacity,1))}.border-indigo-900{--tw-border-opacity:1;border-color:rgb(49 46 129/var(--tw-border-opacity,1))}.border-orange-200{--tw-border-opacity:1;border-color:rgb(254 215 170/var(--tw-border-opacity,1))}.border-pink-100{--tw-border-opacity:1;border-color:rgb(252 231 243/var(--tw-border-opacity,1))}.border-pink-200{--tw-border-opacity:1;border-color:rgb(251 207 232/var(--tw-border-opacity,1))}.border-pink-300{--tw-border-opacity:1;border-color:rgb(249 168 212/var(--tw-border-opacity,1))}.border-pink-400{--tw-border-opacity:1;border-color:rgb(244 114 182/var(--tw-border-opacity,1))}.border-pink-50{--tw-border-opacity:1;border-color:rgb(253 242 248/var(--tw-border-opacity,1))}.border-pink-500{--tw-border-opacity:1;border-color:rgb(236 72 153/var(--tw-border-opacity,1))}.border-pink-600{--tw-border-opacity:1;border-color:rgb(219 39 119/var(--tw-border-opacity,1))}.border-pink-700{--tw-border-opacity:1;border-color:rgb(190 24 93/var(--tw-border-opacity,1))}.border-pink-800{--tw-border-opacity:1;border-color:rgb(157 23 77/var(--tw-border-opacity,1))}.border-pink-900{--tw-border-opacity:1;border-color:rgb(131 24 67/var(--tw-border-opacity,1))}.border-purple-100{--tw-border-opacity:1;border-color:rgb(243 232 255/var(--tw-border-opacity,1))}.border-purple-200{--tw-border-opacity:1;border-color:rgb(233 213 255/var(--tw-border-opacity,1))}.border-purple-300{--tw-border-opacity:1;border-color:rgb(216 180 254/var(--tw-border-opacity,1))}.border-purple-400{--tw-border-opacity:1;border-color:rgb(192 132 252/var(--tw-border-opacity,1))}.border-purple-400\/30{border-color:rgba(192,132,252,.3)}.border-purple-400\/40{border-color:rgba(192,132,252,.4)}.border-purple-50{--tw-border-opacity:1;border-color:rgb(250 245 255/var(--tw-border-opacity,1))}.border-purple-500{--tw-border-opacity:1;border-color:rgb(168 85 247/var(--tw-border-opacity,1))}.border-purple-600{--tw-border-opacity:1;border-color:rgb(147 51 234/var(--tw-border-opacity,1))}.border-purple-700{--tw-border-opacity:1;border-color:rgb(126 34 206/var(--tw-border-opacity,1))}.border-purple-800{--tw-border-opacity:1;border-color:rgb(107 33 168/var(--tw-border-opacity,1))}.border-purple-900{--tw-border-opacity:1;border-color:rgb(88 28 135/var(--tw-border-opacity,1))}.border-red-100{--tw-border-opacity:1;border-color:rgb(254 226 226/var(--tw-border-opacity,1))}.border-red-200{--tw-border-opacity:1;border-color:rgb(254 202 202/var(--tw-border-opacity,1))}.border-red-300{--tw-border-opacity:1;border-color:rgb(252 165 165/var(--tw-border-opacity,1))}.border-red-400{--tw-border-opacity:1;border-color:rgb(248 113 113/var(--tw-border-opacity,1))}.border-red-50{--tw-border-opacity:1;border-color:rgb(254 242 242/var(--tw-border-opacity,1))}.border-red-500{--tw-border-opacity:1;border-color:rgb(239 68 68/var(--tw-border-opacity,1))}.border-red-500\/40{border-color:rgba(239,68,68,.4)}.border-red-600{--tw-border-opacity:1;border-color:rgb(220 38 38/var(--tw-border-opacity,1))}.border-red-700{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.border-red-700\/60{border-color:rgba(185,28,28,.6)}.border-red-800{--tw-border-opacity:1;border-color:rgb(153 27 27/var(--tw-border-opacity,1))}.border-red-800\/70{border-color:rgba(153,27,27,.7)}.border-red-900{--tw-border-opacity:1;border-color:rgb(127 29 29/var(--tw-border-opacity,1))}.border-red-900\/30{border-color:rgba(127,29,29,.3)}.border-red-900\/50{border-color:rgba(127,29,29,.5)}.border-slate-100{--tw-border-opacity:1;border-color:rgb(241 245 249/var(--tw-border-opacity,1))}.border-slate-200{--tw-border-opacity:1;border-color:rgb(226 232 240/var(--tw-border-opacity,1))}.border-slate-300{--tw-border-opacity:1;border-color:rgb(203 213 225/var(--tw-border-opacity,1))}.border-slate-400{--tw-border-opacity:1;border-color:rgb(148 163 184/var(--tw-border-opacity,1))}.border-slate-50{--tw-border-opacity:1;border-color:rgb(248 250 252/var(--tw-border-opacity,1))}.border-slate-500{--tw-border-opacity:1;border-color:rgb(100 116 139/var(--tw-border-opacity,1))}.border-slate-600{--tw-border-opacity:1;border-color:rgb(71 85 105/var(--tw-border-opacity,1))}.border-slate-700{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.border-slate-800{--tw-border-opacity:1;border-color:rgb(30 41 59/var(--tw-border-opacity,1))}.border-slate-900{--tw-border-opacity:1;border-color:rgb(15 23 42/var(--tw-border-opacity,1))}.border-teal-700\/60{border-color:rgba(15,118,110,.6)}.border-violet-100{--tw-border-opacity:1;border-color:rgb(237 233 254/var(--tw-border-opacity,1))}.border-yellow-100{--tw-border-opacity:1;border-color:rgb(254 249 195/var(--tw-border-opacity,1))}.border-yellow-200{--tw-border-opacity:1;border-color:rgb(254 240 138/var(--tw-border-opacity,1))}.border-yellow-300{--tw-border-opacity:1;border-color:rgb(253 224 71/var(--tw-border-opacity,1))}.border-yellow-400{--tw-border-opacity:1;border-color:rgb(250 204 21/var(--tw-border-opacity,1))}.border-yellow-400\/40{border-color:rgba(250,204,21,.4)}.border-yellow-50{--tw-border-opacity:1;border-color:rgb(254 252 232/var(--tw-border-opacity,1))}.border-yellow-500{--tw-border-opacity:1;border-color:rgb(234 179 8/var(--tw-border-opacity,1))}.border-yellow-600{--tw-border-opacity:1;border-color:rgb(202 138 4/var(--tw-border-opacity,1))}.border-yellow-700{--tw-border-opacity:1;border-color:rgb(161 98 7/var(--tw-border-opacity,1))}.border-yellow-800{--tw-border-opacity:1;border-color:rgb(133 77 14/var(--tw-border-opacity,1))}.border-yellow-900{--tw-border-opacity:1;border-color:rgb(113 63 18/var(--tw-border-opacity,1))}.\!bg-accent{--tw-bg-opacity:1!important;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))!important}.bg-accent{--tw-bg-opacity:1;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))}.bg-accent-dark{--tw-bg-opacity:1;background-color:rgb(32 96 176/var(--tw-bg-opacity,1))}.bg-accent-dark\/0{background-color:rgba(32,96,176,0)}.bg-accent-dark\/10{background-color:rgba(32,96,176,.1)}.bg-accent-dark\/100{background-color:#2060b0}.bg-accent-dark\/15{background-color:rgba(32,96,176,.15)}.bg-accent-dark\/20{background-color:rgba(32,96,176,.2)}.bg-accent-dark\/25{background-color:rgba(32,96,176,.25)}.bg-accent-dark\/30{background-color:rgba(32,96,176,.3)}.bg-accent-dark\/35{background-color:rgba(32,96,176,.35)}.bg-accent-dark\/40{background-color:rgba(32,96,176,.4)}.bg-accent-dark\/45{background-color:rgba(32,96,176,.45)}.bg-accent-dark\/5{background-color:rgba(32,96,176,.05)}.bg-accent-dark\/50{background-color:rgba(32,96,176,.5)}.bg-accent-dark\/55{background-color:rgba(32,96,176,.55)}.bg-accent-dark\/60{background-color:rgba(32,96,176,.6)}.bg-accent-dark\/65{background-color:rgba(32,96,176,.65)}.bg-accent-dark\/70{background-color:rgba(32,96,176,.7)}.bg-accent-dark\/75{background-color:rgba(32,96,176,.75)}.bg-accent-dark\/80{background-color:rgba(32,96,176,.8)}.bg-accent-dark\/85{background-color:rgba(32,96,176,.85)}.bg-accent-dark\/90{background-color:rgba(32,96,176,.9)}.bg-accent-dark\/95{background-color:rgba(32,96,176,.95)}.bg-accent-light{--tw-bg-opacity:1;background-color:rgb(96 160 255/var(--tw-bg-opacity,1))}.bg-accent-light\/0{background-color:rgba(96,160,255,0)}.bg-accent-light\/10{background-color:rgba(96,160,255,.1)}.bg-accent-light\/100{background-color:#60a0ff}.bg-accent-light\/15{background-color:rgba(96,160,255,.15)}.bg-accent-light\/20{background-color:rgba(96,160,255,.2)}.bg-accent-light\/25{background-color:rgba(96,160,255,.25)}.bg-accent-light\/30{background-color:rgba(96,160,255,.3)}.bg-accent-light\/35{background-color:rgba(96,160,255,.35)}.bg-accent-light\/40{background-color:rgba(96,160,255,.4)}.bg-accent-light\/45{background-color:rgba(96,160,255,.45)}.bg-accent-light\/5{background-color:rgba(96,160,255,.05)}.bg-accent-light\/50{background-color:rgba(96,160,255,.5)}.bg-accent-light\/55{background-color:rgba(96,160,255,.55)}.bg-accent-light\/60{background-color:rgba(96,160,255,.6)}.bg-accent-light\/65{background-color:rgba(96,160,255,.65)}.bg-accent-light\/70{background-color:rgba(96,160,255,.7)}.bg-accent-light\/75{background-color:rgba(96,160,255,.75)}.bg-accent-light\/80{background-color:rgba(96,160,255,.8)}.bg-accent-light\/85{background-color:rgba(96,160,255,.85)}.bg-accent-light\/90{background-color:rgba(96,160,255,.9)}.bg-accent-light\/95{background-color:rgba(96,160,255,.95)}.bg-accent\/10{background-color:rgba(64,128,224,.1)}.bg-accent\/20{background-color:rgba(64,128,224,.2)}.bg-accent\/30{background-color:rgba(64,128,224,.3)}.bg-amber-100{--tw-bg-opacity:1;background-color:rgb(254 243 199/var(--tw-bg-opacity,1))}.bg-amber-200{--tw-bg-opacity:1;background-color:rgb(253 230 138/var(--tw-bg-opacity,1))}.bg-amber-300{--tw-bg-opacity:1;background-color:rgb(252 211 77/var(--tw-bg-opacity,1))}.bg-amber-400{--tw-bg-opacity:1;background-color:rgb(251 191 36/var(--tw-bg-opacity,1))}.bg-amber-50{--tw-bg-opacity:1;background-color:rgb(255 251 235/var(--tw-bg-opacity,1))}.bg-amber-500{--tw-bg-opacity:1;background-color:rgb(245 158 11/var(--tw-bg-opacity,1))}.bg-amber-500\/10{background-color:rgba(245,158,11,.1)}.bg-amber-500\/5{background-color:rgba(245,158,11,.05)}.bg-amber-600{--tw-bg-opacity:1;background-color:rgb(217 119 6/var(--tw-bg-opacity,1))}.bg-amber-700{--tw-bg-opacity:1;background-color:rgb(180 83 9/var(--tw-bg-opacity,1))}.bg-amber-800{--tw-bg-opacity:1;background-color:rgb(146 64 14/var(--tw-bg-opacity,1))}.bg-amber-900{--tw-bg-opacity:1;background-color:rgb(120 53 15/var(--tw-bg-opacity,1))}.bg-amber-900\/30{background-color:rgba(120,53,15,.3)}.bg-amber-900\/60{background-color:rgba(120,53,15,.6)}.bg-amber-900\/80{background-color:rgba(120,53,15,.8)}.bg-black\/30{background-color:rgba(0,0,0,.3)}.bg-black\/40{background-color:rgba(0,0,0,.4)}.bg-black\/50{background-color:rgba(0,0,0,.5)}.bg-black\/55{background-color:rgba(0,0,0,.55)}.bg-black\/60{background-color:rgba(0,0,0,.6)}.bg-black\/70{background-color:rgba(0,0,0,.7)}.bg-blue-100{--tw-bg-opacity:1;background-color:rgb(219 234 254/var(--tw-bg-opacity,1))}.bg-blue-200{--tw-bg-opacity:1;background-color:rgb(191 219 254/var(--tw-bg-opacity,1))}.bg-blue-300{--tw-bg-opacity:1;background-color:rgb(147 197 253/var(--tw-bg-opacity,1))}.bg-blue-400{--tw-bg-opacity:1;background-color:rgb(96 165 250/var(--tw-bg-opacity,1))}.bg-blue-50{--tw-bg-opacity:1;background-color:rgb(239 246 255/var(--tw-bg-opacity,1))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246/var(--tw-bg-opacity,1))}.bg-blue-500\/10{background-color:rgba(59,130,246,.1)}.bg-blue-600{--tw-bg-opacity:1;background-color:rgb(37 99 235/var(--tw-bg-opacity,1))}.bg-blue-700{--tw-bg-opacity:1;background-color:rgb(29 78 216/var(--tw-bg-opacity,1))}.bg-blue-800{--tw-bg-opacity:1;background-color:rgb(30 64 175/var(--tw-bg-opacity,1))}.bg-blue-900{--tw-bg-opacity:1;background-color:rgb(30 58 138/var(--tw-bg-opacity,1))}.bg-blue-900\/40{background-color:rgba(30,58,138,.4)}.bg-current{background-color:currentColor}.bg-cyan-400{--tw-bg-opacity:1;background-color:rgb(34 211 238/var(--tw-bg-opacity,1))}.bg-dark-500{--tw-bg-opacity:1;background-color:rgb(30 30 58/var(--tw-bg-opacity,1))}.bg-dark-500\/0{background-color:rgba(30,30,58,0)}.bg-dark-500\/10{background-color:rgba(30,30,58,.1)}.bg-dark-500\/100{background-color:#1e1e3a}.bg-dark-500\/15{background-color:rgba(30,30,58,.15)}.bg-dark-500\/20{background-color:rgba(30,30,58,.2)}.bg-dark-500\/25{background-color:rgba(30,30,58,.25)}.bg-dark-500\/30{background-color:rgba(30,30,58,.3)}.bg-dark-500\/35{background-color:rgba(30,30,58,.35)}.bg-dark-500\/40{background-color:rgba(30,30,58,.4)}.bg-dark-500\/45{background-color:rgba(30,30,58,.45)}.bg-dark-500\/5{background-color:rgba(30,30,58,.05)}.bg-dark-500\/50{background-color:rgba(30,30,58,.5)}.bg-dark-500\/55{background-color:rgba(30,30,58,.55)}.bg-dark-500\/60{background-color:rgba(30,30,58,.6)}.bg-dark-500\/65{background-color:rgba(30,30,58,.65)}.bg-dark-500\/70{background-color:rgba(30,30,58,.7)}.bg-dark-500\/75{background-color:rgba(30,30,58,.75)}.bg-dark-500\/80{background-color:rgba(30,30,58,.8)}.bg-dark-500\/85{background-color:rgba(30,30,58,.85)}.bg-dark-500\/90{background-color:rgba(30,30,58,.9)}.bg-dark-500\/95{background-color:rgba(30,30,58,.95)}.bg-dark-600{--tw-bg-opacity:1;background-color:rgb(24 24 48/var(--tw-bg-opacity,1))}.bg-dark-600\/0{background-color:rgba(24,24,48,0)}.bg-dark-600\/10{background-color:rgba(24,24,48,.1)}.bg-dark-600\/100{background-color:#181830}.bg-dark-600\/15{background-color:rgba(24,24,48,.15)}.bg-dark-600\/20{background-color:rgba(24,24,48,.2)}.bg-dark-600\/25{background-color:rgba(24,24,48,.25)}.bg-dark-600\/30{background-color:rgba(24,24,48,.3)}.bg-dark-600\/35{background-color:rgba(24,24,48,.35)}.bg-dark-600\/40{background-color:rgba(24,24,48,.4)}.bg-dark-600\/45{background-color:rgba(24,24,48,.45)}.bg-dark-600\/5{background-color:rgba(24,24,48,.05)}.bg-dark-600\/50{background-color:rgba(24,24,48,.5)}.bg-dark-600\/55{background-color:rgba(24,24,48,.55)}.bg-dark-600\/60{background-color:rgba(24,24,48,.6)}.bg-dark-600\/65{background-color:rgba(24,24,48,.65)}.bg-dark-600\/70{background-color:rgba(24,24,48,.7)}.bg-dark-600\/75{background-color:rgba(24,24,48,.75)}.bg-dark-600\/80{background-color:rgba(24,24,48,.8)}.bg-dark-600\/85{background-color:rgba(24,24,48,.85)}.bg-dark-600\/90{background-color:rgba(24,24,48,.9)}.bg-dark-600\/95{background-color:rgba(24,24,48,.95)}.bg-dark-700{--tw-bg-opacity:1;background-color:rgb(16 16 30/var(--tw-bg-opacity,1))}.bg-dark-700\/0{background-color:rgba(16,16,30,0)}.bg-dark-700\/10{background-color:rgba(16,16,30,.1)}.bg-dark-700\/100{background-color:#10101e}.bg-dark-700\/15{background-color:rgba(16,16,30,.15)}.bg-dark-700\/20{background-color:rgba(16,16,30,.2)}.bg-dark-700\/25{background-color:rgba(16,16,30,.25)}.bg-dark-700\/30{background-color:rgba(16,16,30,.3)}.bg-dark-700\/35{background-color:rgba(16,16,30,.35)}.bg-dark-700\/40{background-color:rgba(16,16,30,.4)}.bg-dark-700\/45{background-color:rgba(16,16,30,.45)}.bg-dark-700\/5{background-color:rgba(16,16,30,.05)}.bg-dark-700\/50{background-color:rgba(16,16,30,.5)}.bg-dark-700\/55{background-color:rgba(16,16,30,.55)}.bg-dark-700\/60{background-color:rgba(16,16,30,.6)}.bg-dark-700\/65{background-color:rgba(16,16,30,.65)}.bg-dark-700\/70{background-color:rgba(16,16,30,.7)}.bg-dark-700\/75{background-color:rgba(16,16,30,.75)}.bg-dark-700\/80{background-color:rgba(16,16,30,.8)}.bg-dark-700\/85{background-color:rgba(16,16,30,.85)}.bg-dark-700\/90{background-color:rgba(16,16,30,.9)}.bg-dark-700\/95{background-color:rgba(16,16,30,.95)}.bg-dark-800{--tw-bg-opacity:1;background-color:rgb(10 10 18/var(--tw-bg-opacity,1))}.bg-dark-800\/0{background-color:rgba(10,10,18,0)}.bg-dark-800\/10{background-color:rgba(10,10,18,.1)}.bg-dark-800\/100{background-color:#0a0a12}.bg-dark-800\/15{background-color:rgba(10,10,18,.15)}.bg-dark-800\/20{background-color:rgba(10,10,18,.2)}.bg-dark-800\/25{background-color:rgba(10,10,18,.25)}.bg-dark-800\/30{background-color:rgba(10,10,18,.3)}.bg-dark-800\/35{background-color:rgba(10,10,18,.35)}.bg-dark-800\/40{background-color:rgba(10,10,18,.4)}.bg-dark-800\/45{background-color:rgba(10,10,18,.45)}.bg-dark-800\/5{background-color:rgba(10,10,18,.05)}.bg-dark-800\/50{background-color:rgba(10,10,18,.5)}.bg-dark-800\/55{background-color:rgba(10,10,18,.55)}.bg-dark-800\/60{background-color:rgba(10,10,18,.6)}.bg-dark-800\/65{background-color:rgba(10,10,18,.65)}.bg-dark-800\/70{background-color:rgba(10,10,18,.7)}.bg-dark-800\/75{background-color:rgba(10,10,18,.75)}.bg-dark-800\/80{background-color:rgba(10,10,18,.8)}.bg-dark-800\/85{background-color:rgba(10,10,18,.85)}.bg-dark-800\/90{background-color:rgba(10,10,18,.9)}.bg-dark-800\/95{background-color:rgba(10,10,18,.95)}.bg-dark-900{--tw-bg-opacity:1;background-color:rgb(5 5 8/var(--tw-bg-opacity,1))}.bg-dark-900\/0{background-color:rgba(5,5,8,0)}.bg-dark-900\/10{background-color:rgba(5,5,8,.1)}.bg-dark-900\/100{background-color:#050508}.bg-dark-900\/15{background-color:rgba(5,5,8,.15)}.bg-dark-900\/20{background-color:rgba(5,5,8,.2)}.bg-dark-900\/25{background-color:rgba(5,5,8,.25)}.bg-dark-900\/30{background-color:rgba(5,5,8,.3)}.bg-dark-900\/35{background-color:rgba(5,5,8,.35)}.bg-dark-900\/40{background-color:rgba(5,5,8,.4)}.bg-dark-900\/45{background-color:rgba(5,5,8,.45)}.bg-dark-900\/5{background-color:rgba(5,5,8,.05)}.bg-dark-900\/50{background-color:rgba(5,5,8,.5)}.bg-dark-900\/55{background-color:rgba(5,5,8,.55)}.bg-dark-900\/60{background-color:rgba(5,5,8,.6)}.bg-dark-900\/65{background-color:rgba(5,5,8,.65)}.bg-dark-900\/70{background-color:rgba(5,5,8,.7)}.bg-dark-900\/75{background-color:rgba(5,5,8,.75)}.bg-dark-900\/80{background-color:rgba(5,5,8,.8)}.bg-dark-900\/85{background-color:rgba(5,5,8,.85)}.bg-dark-900\/90{background-color:rgba(5,5,8,.9)}.bg-dark-900\/95{background-color:rgba(5,5,8,.95)}.bg-emerald-400{--tw-bg-opacity:1;background-color:rgb(52 211 153/var(--tw-bg-opacity,1))}.bg-emerald-500{--tw-bg-opacity:1;background-color:rgb(16 185 129/var(--tw-bg-opacity,1))}.bg-emerald-500\/10{background-color:rgba(16,185,129,.1)}.bg-emerald-500\/5{background-color:rgba(16,185,129,.05)}.bg-emerald-700{--tw-bg-opacity:1;background-color:rgb(4 120 87/var(--tw-bg-opacity,1))}.bg-fb-bg{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-fb-bg\/40{background-color:rgba(15,23,42,.4)}.bg-fb-bg\/50{background-color:rgba(15,23,42,.5)}.bg-fb-bg\/60{background-color:rgba(15,23,42,.6)}.bg-fb-bg\/95{background-color:rgba(15,23,42,.95)}.bg-fb-border{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-fb-card{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-fb-card\/60{background-color:rgba(30,41,59,.6)}.bg-fb-card\/80{background-color:rgba(30,41,59,.8)}.bg-fb-cardMuted{--tw-bg-opacity:1;background-color:rgb(11 18 32/var(--tw-bg-opacity,1))}.bg-fb-good{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-fb-good\/30{background-color:rgba(34,197,94,.3)}.bg-fb-low{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-fb-low\/30{background-color:rgba(239,68,68,.3)}.bg-fb-mid{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-fb-primary{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.bg-fb-primary\/20{background-color:rgba(14,165,233,.2)}.bg-fb-sidebar{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-fb-sidebar\/80{background-color:rgba(17,24,39,.8)}.bg-fb-sidebar\/95{background-color:rgba(17,24,39,.95)}.bg-gold{--tw-bg-opacity:1;background-color:rgb(232 192 64/var(--tw-bg-opacity,1))}.bg-gold\/10{background-color:rgba(232,192,64,.1)}.bg-gold\/5{background-color:rgba(232,192,64,.05)}.bg-gray-100{--tw-bg-opacity:1;background-color:rgb(243 244 246/var(--tw-bg-opacity,1))}.bg-gray-200{--tw-bg-opacity:1;background-color:rgb(229 231 235/var(--tw-bg-opacity,1))}.bg-gray-300{--tw-bg-opacity:1;background-color:rgb(209 213 219/var(--tw-bg-opacity,1))}.bg-gray-400{--tw-bg-opacity:1;background-color:rgb(156 163 175/var(--tw-bg-opacity,1))}.bg-gray-50{--tw-bg-opacity:1;background-color:rgb(249 250 251/var(--tw-bg-opacity,1))}.bg-gray-500{--tw-bg-opacity:1;background-color:rgb(107 114 128/var(--tw-bg-opacity,1))}.bg-gray-500\/40{background-color:hsla(220,9%,46%,.4)}.bg-gray-600{--tw-bg-opacity:1;background-color:rgb(75 85 99/var(--tw-bg-opacity,1))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81/var(--tw-bg-opacity,1))}.bg-gray-800{--tw-bg-opacity:1;background-color:rgb(31 41 55/var(--tw-bg-opacity,1))}.bg-gray-800\/50{background-color:rgba(31,41,55,.5)}.bg-gray-800\/60{background-color:rgba(31,41,55,.6)}.bg-gray-900{--tw-bg-opacity:1;background-color:rgb(17 24 39/var(--tw-bg-opacity,1))}.bg-green-100{--tw-bg-opacity:1;background-color:rgb(220 252 231/var(--tw-bg-opacity,1))}.bg-green-200{--tw-bg-opacity:1;background-color:rgb(187 247 208/var(--tw-bg-opacity,1))}.bg-green-300{--tw-bg-opacity:1;background-color:rgb(134 239 172/var(--tw-bg-opacity,1))}.bg-green-400{--tw-bg-opacity:1;background-color:rgb(74 222 128/var(--tw-bg-opacity,1))}.bg-green-50{--tw-bg-opacity:1;background-color:rgb(240 253 244/var(--tw-bg-opacity,1))}.bg-green-500{--tw-bg-opacity:1;background-color:rgb(34 197 94/var(--tw-bg-opacity,1))}.bg-green-600{--tw-bg-opacity:1;background-color:rgb(22 163 74/var(--tw-bg-opacity,1))}.bg-green-700{--tw-bg-opacity:1;background-color:rgb(21 128 61/var(--tw-bg-opacity,1))}.bg-green-800{--tw-bg-opacity:1;background-color:rgb(22 101 52/var(--tw-bg-opacity,1))}.bg-green-900{--tw-bg-opacity:1;background-color:rgb(20 83 45/var(--tw-bg-opacity,1))}.bg-green-900\/30{background-color:rgba(20,83,45,.3)}.bg-green-900\/40{background-color:rgba(20,83,45,.4)}.bg-green-900\/50{background-color:rgba(20,83,45,.5)}.bg-green-900\/60{background-color:rgba(20,83,45,.6)}.bg-green-900\/80{background-color:rgba(20,83,45,.8)}.bg-indigo-100{--tw-bg-opacity:1;background-color:rgb(224 231 255/var(--tw-bg-opacity,1))}.bg-indigo-200{--tw-bg-opacity:1;background-color:rgb(199 210 254/var(--tw-bg-opacity,1))}.bg-indigo-300{--tw-bg-opacity:1;background-color:rgb(165 180 252/var(--tw-bg-opacity,1))}.bg-indigo-400{--tw-bg-opacity:1;background-color:rgb(129 140 248/var(--tw-bg-opacity,1))}.bg-indigo-50{--tw-bg-opacity:1;background-color:rgb(238 242 255/var(--tw-bg-opacity,1))}.bg-indigo-500{--tw-bg-opacity:1;background-color:rgb(99 102 241/var(--tw-bg-opacity,1))}.bg-indigo-600{--tw-bg-opacity:1;background-color:rgb(79 70 229/var(--tw-bg-opacity,1))}.bg-indigo-700{--tw-bg-opacity:1;background-color:rgb(67 56 202/var(--tw-bg-opacity,1))}.bg-indigo-800{--tw-bg-opacity:1;background-color:rgb(55 48 163/var(--tw-bg-opacity,1))}.bg-indigo-900{--tw-bg-opacity:1;background-color:rgb(49 46 129/var(--tw-bg-opacity,1))}.bg-orange-400{--tw-bg-opacity:1;background-color:rgb(251 146 60/var(--tw-bg-opacity,1))}.bg-orange-500{--tw-bg-opacity:1;background-color:rgb(249 115 22/var(--tw-bg-opacity,1))}.bg-orange-600{--tw-bg-opacity:1;background-color:rgb(234 88 12/var(--tw-bg-opacity,1))}.bg-pink-100{--tw-bg-opacity:1;background-color:rgb(252 231 243/var(--tw-bg-opacity,1))}.bg-pink-200{--tw-bg-opacity:1;background-color:rgb(251 207 232/var(--tw-bg-opacity,1))}.bg-pink-300{--tw-bg-opacity:1;background-color:rgb(249 168 212/var(--tw-bg-opacity,1))}.bg-pink-400{--tw-bg-opacity:1;background-color:rgb(244 114 182/var(--tw-bg-opacity,1))}.bg-pink-50{--tw-bg-opacity:1;background-color:rgb(253 242 248/var(--tw-bg-opacity,1))}.bg-pink-500{--tw-bg-opacity:1;background-color:rgb(236 72 153/var(--tw-bg-opacity,1))}.bg-pink-600{--tw-bg-opacity:1;background-color:rgb(219 39 119/var(--tw-bg-opacity,1))}.bg-pink-700{--tw-bg-opacity:1;background-color:rgb(190 24 93/var(--tw-bg-opacity,1))}.bg-pink-800{--tw-bg-opacity:1;background-color:rgb(157 23 77/var(--tw-bg-opacity,1))}.bg-pink-900{--tw-bg-opacity:1;background-color:rgb(131 24 67/var(--tw-bg-opacity,1))}.bg-purple-100{--tw-bg-opacity:1;background-color:rgb(243 232 255/var(--tw-bg-opacity,1))}.bg-purple-200{--tw-bg-opacity:1;background-color:rgb(233 213 255/var(--tw-bg-opacity,1))}.bg-purple-300{--tw-bg-opacity:1;background-color:rgb(216 180 254/var(--tw-bg-opacity,1))}.bg-purple-400{--tw-bg-opacity:1;background-color:rgb(192 132 252/var(--tw-bg-opacity,1))}.bg-purple-50{--tw-bg-opacity:1;background-color:rgb(250 245 255/var(--tw-bg-opacity,1))}.bg-purple-500{--tw-bg-opacity:1;background-color:rgb(168 85 247/var(--tw-bg-opacity,1))}.bg-purple-500\/10{background-color:rgba(168,85,247,.1)}.bg-purple-500\/20{background-color:rgba(168,85,247,.2)}.bg-purple-500\/40{background-color:rgba(168,85,247,.4)}.bg-purple-600{--tw-bg-opacity:1;background-color:rgb(147 51 234/var(--tw-bg-opacity,1))}.bg-purple-700{--tw-bg-opacity:1;background-color:rgb(126 34 206/var(--tw-bg-opacity,1))}.bg-purple-800{--tw-bg-opacity:1;background-color:rgb(107 33 168/var(--tw-bg-opacity,1))}.bg-purple-900{--tw-bg-opacity:1;background-color:rgb(88 28 135/var(--tw-bg-opacity,1))}.bg-purple-900\/30{background-color:rgba(88,28,135,.3)}.bg-purple-900\/40{background-color:rgba(88,28,135,.4)}.bg-purple-900\/60{background-color:rgba(88,28,135,.6)}.bg-purple-900\/80{background-color:rgba(88,28,135,.8)}.bg-red-100{--tw-bg-opacity:1;background-color:rgb(254 226 226/var(--tw-bg-opacity,1))}.bg-red-200{--tw-bg-opacity:1;background-color:rgb(254 202 202/var(--tw-bg-opacity,1))}.bg-red-300{--tw-bg-opacity:1;background-color:rgb(252 165 165/var(--tw-bg-opacity,1))}.bg-red-400{--tw-bg-opacity:1;background-color:rgb(248 113 113/var(--tw-bg-opacity,1))}.bg-red-50{--tw-bg-opacity:1;background-color:rgb(254 242 242/var(--tw-bg-opacity,1))}.bg-red-500{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.bg-red-500\/10{background-color:rgba(239,68,68,.1)}.bg-red-500\/5{background-color:rgba(239,68,68,.05)}.bg-red-600{--tw-bg-opacity:1;background-color:rgb(220 38 38/var(--tw-bg-opacity,1))}.bg-red-600\/80{background-color:rgba(220,38,38,.8)}.bg-red-700{--tw-bg-opacity:1;background-color:rgb(185 28 28/var(--tw-bg-opacity,1))}.bg-red-800{--tw-bg-opacity:1;background-color:rgb(153 27 27/var(--tw-bg-opacity,1))}.bg-red-900{--tw-bg-opacity:1;background-color:rgb(127 29 29/var(--tw-bg-opacity,1))}.bg-red-900\/10{background-color:rgba(127,29,29,.1)}.bg-red-900\/30{background-color:rgba(127,29,29,.3)}.bg-red-900\/40{background-color:rgba(127,29,29,.4)}.bg-red-900\/95{background-color:rgba(127,29,29,.95)}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity,1))}.bg-slate-200{--tw-bg-opacity:1;background-color:rgb(226 232 240/var(--tw-bg-opacity,1))}.bg-slate-300{--tw-bg-opacity:1;background-color:rgb(203 213 225/var(--tw-bg-opacity,1))}.bg-slate-400{--tw-bg-opacity:1;background-color:rgb(148 163 184/var(--tw-bg-opacity,1))}.bg-slate-50{--tw-bg-opacity:1;background-color:rgb(248 250 252/var(--tw-bg-opacity,1))}.bg-slate-500{--tw-bg-opacity:1;background-color:rgb(100 116 139/var(--tw-bg-opacity,1))}.bg-slate-600{--tw-bg-opacity:1;background-color:rgb(71 85 105/var(--tw-bg-opacity,1))}.bg-slate-700{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.bg-slate-800{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.bg-slate-900{--tw-bg-opacity:1;background-color:rgb(15 23 42/var(--tw-bg-opacity,1))}.bg-teal-500\/10{background-color:rgba(20,184,166,.1)}.bg-transparent{background-color:transparent}.bg-violet-200{--tw-bg-opacity:1;background-color:rgb(221 214 254/var(--tw-bg-opacity,1))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.bg-yellow-100{--tw-bg-opacity:1;background-color:rgb(254 249 195/var(--tw-bg-opacity,1))}.bg-yellow-200{--tw-bg-opacity:1;background-color:rgb(254 240 138/var(--tw-bg-opacity,1))}.bg-yellow-300{--tw-bg-opacity:1;background-color:rgb(253 224 71/var(--tw-bg-opacity,1))}.bg-yellow-400{--tw-bg-opacity:1;background-color:rgb(250 204 21/var(--tw-bg-opacity,1))}.bg-yellow-50{--tw-bg-opacity:1;background-color:rgb(254 252 232/var(--tw-bg-opacity,1))}.bg-yellow-500{--tw-bg-opacity:1;background-color:rgb(234 179 8/var(--tw-bg-opacity,1))}.bg-yellow-500\/10{background-color:rgba(234,179,8,.1)}.bg-yellow-600{--tw-bg-opacity:1;background-color:rgb(202 138 4/var(--tw-bg-opacity,1))}.bg-yellow-700{--tw-bg-opacity:1;background-color:rgb(161 98 7/var(--tw-bg-opacity,1))}.bg-yellow-800{--tw-bg-opacity:1;background-color:rgb(133 77 14/var(--tw-bg-opacity,1))}.bg-yellow-900{--tw-bg-opacity:1;background-color:rgb(113 63 18/var(--tw-bg-opacity,1))}.bg-yellow-900\/30{background-color:rgba(113,63,18,.3)}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-accent-light{--tw-gradient-from:#60a0ff var(--tw-gradient-from-position);--tw-gradient-to:rgba(96,160,255,0) var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/70{--tw-gradient-from:rgba(0,0,0,.7) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/80{--tw-gradient-from:rgba(0,0,0,.8) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.from-black\/90{--tw-gradient-from:rgba(0,0,0,.9) var(--tw-gradient-from-position);--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),var(--tw-gradient-to)}.via-black\/40{--tw-gradient-to:transparent var(--tw-gradient-to-position);--tw-gradient-stops:var(--tw-gradient-from),rgba(0,0,0,.4) var(--tw-gradient-via-position),var(--tw-gradient-to)}.to-purple-400{--tw-gradient-to:#c084fc var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to:transparent var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.object-cover{-o-object-fit:cover;object-fit:cover}.object-center{-o-object-position:center;object-position:center}.object-right{-o-object-position:right;object-position:right}.p-0{padding:0}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-0\.5{padding-left:.125rem;padding-right:.125rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-px{padding-top:1px;padding-bottom:1px}.pb-1{padding-bottom:.25rem}.pb-10{padding-bottom:2.5rem}.pb-16{padding-bottom:4rem}.pb-2{padding-bottom:.5rem}.pb-4{padding-bottom:1rem}.pb-6{padding-bottom:1.5rem}.pb-8{padding-bottom:2rem}.pl-0{padding-left:0}.pl-10{padding-left:2.5rem}.pl-3{padding-left:.75rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pr-0{padding-right:0}.pr-1{padding-right:.25rem}.pr-2{padding-right:.5rem}.pr-3{padding-right:.75rem}.pr-4{padding-right:1rem}.pt-1{padding-top:.25rem}.pt-2{padding-top:.5rem}.pt-24{padding-top:6rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.align-middle{vertical-align:middle}.font-display{font-family:Inter,system-ui,sans-serif}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-6xl{font-size:3.75rem;line-height:1}.text-9xl{font-size:8rem;line-height:1}.text-\[0\.625rem\]{font-size:.625rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-black{font-weight:900}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-5{line-height:1.25rem}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-tighter{letter-spacing:-.05em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.\!text-white{--tw-text-opacity:1!important;color:rgb(255 255 255/var(--tw-text-opacity,1))!important}.text-\[\#FF4B4B\]{--tw-text-opacity:1;color:rgb(255 75 75/var(--tw-text-opacity,1))}.text-accent{--tw-text-opacity:1;color:rgb(64 128 224/var(--tw-text-opacity,1))}.text-accent-dark{--tw-text-opacity:1;color:rgb(32 96 176/var(--tw-text-opacity,1))}.text-accent-dark\/0{color:rgba(32,96,176,0)}.text-accent-dark\/10{color:rgba(32,96,176,.1)}.text-accent-dark\/100{color:#2060b0}.text-accent-dark\/15{color:rgba(32,96,176,.15)}.text-accent-dark\/20{color:rgba(32,96,176,.2)}.text-accent-dark\/25{color:rgba(32,96,176,.25)}.text-accent-dark\/30{color:rgba(32,96,176,.3)}.text-accent-dark\/35{color:rgba(32,96,176,.35)}.text-accent-dark\/40{color:rgba(32,96,176,.4)}.text-accent-dark\/45{color:rgba(32,96,176,.45)}.text-accent-dark\/5{color:rgba(32,96,176,.05)}.text-accent-dark\/50{color:rgba(32,96,176,.5)}.text-accent-dark\/55{color:rgba(32,96,176,.55)}.text-accent-dark\/60{color:rgba(32,96,176,.6)}.text-accent-dark\/65{color:rgba(32,96,176,.65)}.text-accent-dark\/70{color:rgba(32,96,176,.7)}.text-accent-dark\/75{color:rgba(32,96,176,.75)}.text-accent-dark\/80{color:rgba(32,96,176,.8)}.text-accent-dark\/85{color:rgba(32,96,176,.85)}.text-accent-dark\/90{color:rgba(32,96,176,.9)}.text-accent-dark\/95{color:rgba(32,96,176,.95)}.text-accent-light{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.text-accent-light\/0{color:rgba(96,160,255,0)}.text-accent-light\/10{color:rgba(96,160,255,.1)}.text-accent-light\/100{color:#60a0ff}.text-accent-light\/15{color:rgba(96,160,255,.15)}.text-accent-light\/20{color:rgba(96,160,255,.2)}.text-accent-light\/25{color:rgba(96,160,255,.25)}.text-accent-light\/30{color:rgba(96,160,255,.3)}.text-accent-light\/35{color:rgba(96,160,255,.35)}.text-accent-light\/40{color:rgba(96,160,255,.4)}.text-accent-light\/45{color:rgba(96,160,255,.45)}.text-accent-light\/5{color:rgba(96,160,255,.05)}.text-accent-light\/50{color:rgba(96,160,255,.5)}.text-accent-light\/55{color:rgba(96,160,255,.55)}.text-accent-light\/60{color:rgba(96,160,255,.6)}.text-accent-light\/65{color:rgba(96,160,255,.65)}.text-accent-light\/70{color:rgba(96,160,255,.7)}.text-accent-light\/75{color:rgba(96,160,255,.75)}.text-accent-light\/80{color:rgba(96,160,255,.8)}.text-accent-light\/85{color:rgba(96,160,255,.85)}.text-accent-light\/90{color:rgba(96,160,255,.9)}.text-accent-light\/95{color:rgba(96,160,255,.95)}.text-accent\/70{color:rgba(64,128,224,.7)}.text-amber-100{--tw-text-opacity:1;color:rgb(254 243 199/var(--tw-text-opacity,1))}.text-amber-200{--tw-text-opacity:1;color:rgb(253 230 138/var(--tw-text-opacity,1))}.text-amber-300{--tw-text-opacity:1;color:rgb(252 211 77/var(--tw-text-opacity,1))}.text-amber-400{--tw-text-opacity:1;color:rgb(251 191 36/var(--tw-text-opacity,1))}.text-amber-400\/90{color:rgba(251,191,36,.9)}.text-amber-50{--tw-text-opacity:1;color:rgb(255 251 235/var(--tw-text-opacity,1))}.text-amber-500{--tw-text-opacity:1;color:rgb(245 158 11/var(--tw-text-opacity,1))}.text-amber-600{--tw-text-opacity:1;color:rgb(217 119 6/var(--tw-text-opacity,1))}.text-amber-700{--tw-text-opacity:1;color:rgb(180 83 9/var(--tw-text-opacity,1))}.text-amber-800{--tw-text-opacity:1;color:rgb(146 64 14/var(--tw-text-opacity,1))}.text-amber-900{--tw-text-opacity:1;color:rgb(120 53 15/var(--tw-text-opacity,1))}.text-black{--tw-text-opacity:1;color:rgb(0 0 0/var(--tw-text-opacity,1))}.text-blue-100{--tw-text-opacity:1;color:rgb(219 234 254/var(--tw-text-opacity,1))}.text-blue-200{--tw-text-opacity:1;color:rgb(191 219 254/var(--tw-text-opacity,1))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253/var(--tw-text-opacity,1))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.text-blue-400\/70{color:rgba(96,165,250,.7)}.text-blue-50{--tw-text-opacity:1;color:rgb(239 246 255/var(--tw-text-opacity,1))}.text-blue-500{--tw-text-opacity:1;color:rgb(59 130 246/var(--tw-text-opacity,1))}.text-blue-600{--tw-text-opacity:1;color:rgb(37 99 235/var(--tw-text-opacity,1))}.text-blue-700{--tw-text-opacity:1;color:rgb(29 78 216/var(--tw-text-opacity,1))}.text-blue-800{--tw-text-opacity:1;color:rgb(30 64 175/var(--tw-text-opacity,1))}.text-blue-900{--tw-text-opacity:1;color:rgb(30 58 138/var(--tw-text-opacity,1))}.text-cyan-300{--tw-text-opacity:1;color:rgb(103 232 249/var(--tw-text-opacity,1))}.text-cyan-300\/90{color:rgba(103,232,249,.9)}.text-cyan-400{--tw-text-opacity:1;color:rgb(34 211 238/var(--tw-text-opacity,1))}.text-dark-500{--tw-text-opacity:1;color:rgb(30 30 58/var(--tw-text-opacity,1))}.text-dark-500\/0{color:rgba(30,30,58,0)}.text-dark-500\/10{color:rgba(30,30,58,.1)}.text-dark-500\/100{color:#1e1e3a}.text-dark-500\/15{color:rgba(30,30,58,.15)}.text-dark-500\/20{color:rgba(30,30,58,.2)}.text-dark-500\/25{color:rgba(30,30,58,.25)}.text-dark-500\/30{color:rgba(30,30,58,.3)}.text-dark-500\/35{color:rgba(30,30,58,.35)}.text-dark-500\/40{color:rgba(30,30,58,.4)}.text-dark-500\/45{color:rgba(30,30,58,.45)}.text-dark-500\/5{color:rgba(30,30,58,.05)}.text-dark-500\/50{color:rgba(30,30,58,.5)}.text-dark-500\/55{color:rgba(30,30,58,.55)}.text-dark-500\/60{color:rgba(30,30,58,.6)}.text-dark-500\/65{color:rgba(30,30,58,.65)}.text-dark-500\/70{color:rgba(30,30,58,.7)}.text-dark-500\/75{color:rgba(30,30,58,.75)}.text-dark-500\/80{color:rgba(30,30,58,.8)}.text-dark-500\/85{color:rgba(30,30,58,.85)}.text-dark-500\/90{color:rgba(30,30,58,.9)}.text-dark-500\/95{color:rgba(30,30,58,.95)}.text-dark-600{--tw-text-opacity:1;color:rgb(24 24 48/var(--tw-text-opacity,1))}.text-dark-600\/0{color:rgba(24,24,48,0)}.text-dark-600\/10{color:rgba(24,24,48,.1)}.text-dark-600\/100{color:#181830}.text-dark-600\/15{color:rgba(24,24,48,.15)}.text-dark-600\/20{color:rgba(24,24,48,.2)}.text-dark-600\/25{color:rgba(24,24,48,.25)}.text-dark-600\/30{color:rgba(24,24,48,.3)}.text-dark-600\/35{color:rgba(24,24,48,.35)}.text-dark-600\/40{color:rgba(24,24,48,.4)}.text-dark-600\/45{color:rgba(24,24,48,.45)}.text-dark-600\/5{color:rgba(24,24,48,.05)}.text-dark-600\/50{color:rgba(24,24,48,.5)}.text-dark-600\/55{color:rgba(24,24,48,.55)}.text-dark-600\/60{color:rgba(24,24,48,.6)}.text-dark-600\/65{color:rgba(24,24,48,.65)}.text-dark-600\/70{color:rgba(24,24,48,.7)}.text-dark-600\/75{color:rgba(24,24,48,.75)}.text-dark-600\/80{color:rgba(24,24,48,.8)}.text-dark-600\/85{color:rgba(24,24,48,.85)}.text-dark-600\/90{color:rgba(24,24,48,.9)}.text-dark-600\/95{color:rgba(24,24,48,.95)}.text-dark-700{--tw-text-opacity:1;color:rgb(16 16 30/var(--tw-text-opacity,1))}.text-dark-700\/0{color:rgba(16,16,30,0)}.text-dark-700\/10{color:rgba(16,16,30,.1)}.text-dark-700\/100{color:#10101e}.text-dark-700\/15{color:rgba(16,16,30,.15)}.text-dark-700\/20{color:rgba(16,16,30,.2)}.text-dark-700\/25{color:rgba(16,16,30,.25)}.text-dark-700\/30{color:rgba(16,16,30,.3)}.text-dark-700\/35{color:rgba(16,16,30,.35)}.text-dark-700\/40{color:rgba(16,16,30,.4)}.text-dark-700\/45{color:rgba(16,16,30,.45)}.text-dark-700\/5{color:rgba(16,16,30,.05)}.text-dark-700\/50{color:rgba(16,16,30,.5)}.text-dark-700\/55{color:rgba(16,16,30,.55)}.text-dark-700\/60{color:rgba(16,16,30,.6)}.text-dark-700\/65{color:rgba(16,16,30,.65)}.text-dark-700\/70{color:rgba(16,16,30,.7)}.text-dark-700\/75{color:rgba(16,16,30,.75)}.text-dark-700\/80{color:rgba(16,16,30,.8)}.text-dark-700\/85{color:rgba(16,16,30,.85)}.text-dark-700\/90{color:rgba(16,16,30,.9)}.text-dark-700\/95{color:rgba(16,16,30,.95)}.text-dark-800{--tw-text-opacity:1;color:rgb(10 10 18/var(--tw-text-opacity,1))}.text-dark-800\/0{color:rgba(10,10,18,0)}.text-dark-800\/10{color:rgba(10,10,18,.1)}.text-dark-800\/100{color:#0a0a12}.text-dark-800\/15{color:rgba(10,10,18,.15)}.text-dark-800\/20{color:rgba(10,10,18,.2)}.text-dark-800\/25{color:rgba(10,10,18,.25)}.text-dark-800\/30{color:rgba(10,10,18,.3)}.text-dark-800\/35{color:rgba(10,10,18,.35)}.text-dark-800\/40{color:rgba(10,10,18,.4)}.text-dark-800\/45{color:rgba(10,10,18,.45)}.text-dark-800\/5{color:rgba(10,10,18,.05)}.text-dark-800\/50{color:rgba(10,10,18,.5)}.text-dark-800\/55{color:rgba(10,10,18,.55)}.text-dark-800\/60{color:rgba(10,10,18,.6)}.text-dark-800\/65{color:rgba(10,10,18,.65)}.text-dark-800\/70{color:rgba(10,10,18,.7)}.text-dark-800\/75{color:rgba(10,10,18,.75)}.text-dark-800\/80{color:rgba(10,10,18,.8)}.text-dark-800\/85{color:rgba(10,10,18,.85)}.text-dark-800\/90{color:rgba(10,10,18,.9)}.text-dark-800\/95{color:rgba(10,10,18,.95)}.text-dark-900{--tw-text-opacity:1;color:rgb(5 5 8/var(--tw-text-opacity,1))}.text-dark-900\/0{color:rgba(5,5,8,0)}.text-dark-900\/10{color:rgba(5,5,8,.1)}.text-dark-900\/100{color:#050508}.text-dark-900\/15{color:rgba(5,5,8,.15)}.text-dark-900\/20{color:rgba(5,5,8,.2)}.text-dark-900\/25{color:rgba(5,5,8,.25)}.text-dark-900\/30{color:rgba(5,5,8,.3)}.text-dark-900\/35{color:rgba(5,5,8,.35)}.text-dark-900\/40{color:rgba(5,5,8,.4)}.text-dark-900\/45{color:rgba(5,5,8,.45)}.text-dark-900\/5{color:rgba(5,5,8,.05)}.text-dark-900\/50{color:rgba(5,5,8,.5)}.text-dark-900\/55{color:rgba(5,5,8,.55)}.text-dark-900\/60{color:rgba(5,5,8,.6)}.text-dark-900\/65{color:rgba(5,5,8,.65)}.text-dark-900\/70{color:rgba(5,5,8,.7)}.text-dark-900\/75{color:rgba(5,5,8,.75)}.text-dark-900\/80{color:rgba(5,5,8,.8)}.text-dark-900\/85{color:rgba(5,5,8,.85)}.text-dark-900\/90{color:rgba(5,5,8,.9)}.text-dark-900\/95{color:rgba(5,5,8,.95)}.text-emerald-300{--tw-text-opacity:1;color:rgb(110 231 183/var(--tw-text-opacity,1))}.text-emerald-400{--tw-text-opacity:1;color:rgb(52 211 153/var(--tw-text-opacity,1))}.text-fb-accent{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-fb-gold{--tw-text-opacity:1;color:rgb(232 192 64/var(--tw-text-opacity,1))}.text-fb-good{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-fb-low{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-fb-mid{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-fb-primary{--tw-text-opacity:1;color:rgb(14 165 233/var(--tw-text-opacity,1))}.text-fb-text{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.text-fb-text\/80{color:rgba(248,250,252,.8)}.text-fb-textDim{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-fb-textDim\/40{color:rgba(148,163,184,.4)}.text-fb-textDim\/60{color:rgba(148,163,184,.6)}.text-fb-textDim\/70{color:rgba(148,163,184,.7)}.text-gold{--tw-text-opacity:1;color:rgb(232 192 64/var(--tw-text-opacity,1))}.text-gray-100{--tw-text-opacity:1;color:rgb(243 244 246/var(--tw-text-opacity,1))}.text-gray-200{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219/var(--tw-text-opacity,1))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.text-gray-50{--tw-text-opacity:1;color:rgb(249 250 251/var(--tw-text-opacity,1))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity,1))}.text-gray-600{--tw-text-opacity:1;color:rgb(75 85 99/var(--tw-text-opacity,1))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity,1))}.text-gray-800{--tw-text-opacity:1;color:rgb(31 41 55/var(--tw-text-opacity,1))}.text-gray-900{--tw-text-opacity:1;color:rgb(17 24 39/var(--tw-text-opacity,1))}.text-green-100{--tw-text-opacity:1;color:rgb(220 252 231/var(--tw-text-opacity,1))}.text-green-200{--tw-text-opacity:1;color:rgb(187 247 208/var(--tw-text-opacity,1))}.text-green-300{--tw-text-opacity:1;color:rgb(134 239 172/var(--tw-text-opacity,1))}.text-green-400{--tw-text-opacity:1;color:rgb(74 222 128/var(--tw-text-opacity,1))}.text-green-50{--tw-text-opacity:1;color:rgb(240 253 244/var(--tw-text-opacity,1))}.text-green-500{--tw-text-opacity:1;color:rgb(34 197 94/var(--tw-text-opacity,1))}.text-green-600{--tw-text-opacity:1;color:rgb(22 163 74/var(--tw-text-opacity,1))}.text-green-700{--tw-text-opacity:1;color:rgb(21 128 61/var(--tw-text-opacity,1))}.text-green-800{--tw-text-opacity:1;color:rgb(22 101 52/var(--tw-text-opacity,1))}.text-green-900{--tw-text-opacity:1;color:rgb(20 83 45/var(--tw-text-opacity,1))}.text-indigo-100{--tw-text-opacity:1;color:rgb(224 231 255/var(--tw-text-opacity,1))}.text-indigo-200{--tw-text-opacity:1;color:rgb(199 210 254/var(--tw-text-opacity,1))}.text-indigo-300{--tw-text-opacity:1;color:rgb(165 180 252/var(--tw-text-opacity,1))}.text-indigo-400{--tw-text-opacity:1;color:rgb(129 140 248/var(--tw-text-opacity,1))}.text-indigo-50{--tw-text-opacity:1;color:rgb(238 242 255/var(--tw-text-opacity,1))}.text-indigo-500{--tw-text-opacity:1;color:rgb(99 102 241/var(--tw-text-opacity,1))}.text-indigo-600{--tw-text-opacity:1;color:rgb(79 70 229/var(--tw-text-opacity,1))}.text-indigo-700{--tw-text-opacity:1;color:rgb(67 56 202/var(--tw-text-opacity,1))}.text-indigo-800{--tw-text-opacity:1;color:rgb(55 48 163/var(--tw-text-opacity,1))}.text-indigo-900{--tw-text-opacity:1;color:rgb(49 46 129/var(--tw-text-opacity,1))}.text-pink-100{--tw-text-opacity:1;color:rgb(252 231 243/var(--tw-text-opacity,1))}.text-pink-200{--tw-text-opacity:1;color:rgb(251 207 232/var(--tw-text-opacity,1))}.text-pink-300{--tw-text-opacity:1;color:rgb(249 168 212/var(--tw-text-opacity,1))}.text-pink-400{--tw-text-opacity:1;color:rgb(244 114 182/var(--tw-text-opacity,1))}.text-pink-50{--tw-text-opacity:1;color:rgb(253 242 248/var(--tw-text-opacity,1))}.text-pink-500{--tw-text-opacity:1;color:rgb(236 72 153/var(--tw-text-opacity,1))}.text-pink-600{--tw-text-opacity:1;color:rgb(219 39 119/var(--tw-text-opacity,1))}.text-pink-700{--tw-text-opacity:1;color:rgb(190 24 93/var(--tw-text-opacity,1))}.text-pink-800{--tw-text-opacity:1;color:rgb(157 23 77/var(--tw-text-opacity,1))}.text-pink-900{--tw-text-opacity:1;color:rgb(131 24 67/var(--tw-text-opacity,1))}.text-purple-100{--tw-text-opacity:1;color:rgb(243 232 255/var(--tw-text-opacity,1))}.text-purple-200{--tw-text-opacity:1;color:rgb(233 213 255/var(--tw-text-opacity,1))}.text-purple-300{--tw-text-opacity:1;color:rgb(216 180 254/var(--tw-text-opacity,1))}.text-purple-400{--tw-text-opacity:1;color:rgb(192 132 252/var(--tw-text-opacity,1))}.text-purple-50{--tw-text-opacity:1;color:rgb(250 245 255/var(--tw-text-opacity,1))}.text-purple-500{--tw-text-opacity:1;color:rgb(168 85 247/var(--tw-text-opacity,1))}.text-purple-600{--tw-text-opacity:1;color:rgb(147 51 234/var(--tw-text-opacity,1))}.text-purple-700{--tw-text-opacity:1;color:rgb(126 34 206/var(--tw-text-opacity,1))}.text-purple-800{--tw-text-opacity:1;color:rgb(107 33 168/var(--tw-text-opacity,1))}.text-purple-900{--tw-text-opacity:1;color:rgb(88 28 135/var(--tw-text-opacity,1))}.text-red-100{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.text-red-200{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.text-red-400{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.text-red-400\/90{color:hsla(0,91%,71%,.9)}.text-red-50{--tw-text-opacity:1;color:rgb(254 242 242/var(--tw-text-opacity,1))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.text-red-600{--tw-text-opacity:1;color:rgb(220 38 38/var(--tw-text-opacity,1))}.text-red-700{--tw-text-opacity:1;color:rgb(185 28 28/var(--tw-text-opacity,1))}.text-red-800{--tw-text-opacity:1;color:rgb(153 27 27/var(--tw-text-opacity,1))}.text-red-900{--tw-text-opacity:1;color:rgb(127 29 29/var(--tw-text-opacity,1))}.text-sky-500{--tw-text-opacity:1;color:rgb(14 165 233/var(--tw-text-opacity,1))}.text-slate-100{--tw-text-opacity:1;color:rgb(241 245 249/var(--tw-text-opacity,1))}.text-slate-200{--tw-text-opacity:1;color:rgb(226 232 240/var(--tw-text-opacity,1))}.text-slate-300{--tw-text-opacity:1;color:rgb(203 213 225/var(--tw-text-opacity,1))}.text-slate-400{--tw-text-opacity:1;color:rgb(148 163 184/var(--tw-text-opacity,1))}.text-slate-50{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.text-slate-500{--tw-text-opacity:1;color:rgb(100 116 139/var(--tw-text-opacity,1))}.text-slate-600{--tw-text-opacity:1;color:rgb(71 85 105/var(--tw-text-opacity,1))}.text-slate-700{--tw-text-opacity:1;color:rgb(51 65 85/var(--tw-text-opacity,1))}.text-slate-800{--tw-text-opacity:1;color:rgb(30 41 59/var(--tw-text-opacity,1))}.text-slate-900{--tw-text-opacity:1;color:rgb(15 23 42/var(--tw-text-opacity,1))}.text-teal-300{--tw-text-opacity:1;color:rgb(94 234 212/var(--tw-text-opacity,1))}.text-transparent{color:transparent}.text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.text-white\/30{color:hsla(0,0%,100%,.3)}.text-yellow-100{--tw-text-opacity:1;color:rgb(254 249 195/var(--tw-text-opacity,1))}.text-yellow-200{--tw-text-opacity:1;color:rgb(254 240 138/var(--tw-text-opacity,1))}.text-yellow-300{--tw-text-opacity:1;color:rgb(253 224 71/var(--tw-text-opacity,1))}.text-yellow-300\/80{color:rgba(253,224,71,.8)}.text-yellow-400{--tw-text-opacity:1;color:rgb(250 204 21/var(--tw-text-opacity,1))}.text-yellow-50{--tw-text-opacity:1;color:rgb(254 252 232/var(--tw-text-opacity,1))}.text-yellow-500{--tw-text-opacity:1;color:rgb(234 179 8/var(--tw-text-opacity,1))}.text-yellow-600{--tw-text-opacity:1;color:rgb(202 138 4/var(--tw-text-opacity,1))}.text-yellow-700{--tw-text-opacity:1;color:rgb(161 98 7/var(--tw-text-opacity,1))}.text-yellow-800{--tw-text-opacity:1;color:rgb(133 77 14/var(--tw-text-opacity,1))}.text-yellow-900{--tw-text-opacity:1;color:rgb(113 63 18/var(--tw-text-opacity,1))}.underline{text-decoration-line:underline}.overline{text-decoration-line:overline}.line-through{text-decoration-line:line-through}.placeholder-fb-textDim::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(148 163 184/var(--tw-placeholder-opacity,1))}.placeholder-fb-textDim::placeholder{--tw-placeholder-opacity:1;color:rgb(148 163 184/var(--tw-placeholder-opacity,1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity:1;color:rgb(107 114 128/var(--tw-placeholder-opacity,1))}.placeholder-gray-600::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity,1))}.placeholder-gray-600::placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity,1))}.accent-accent{accent-color:#4080e0}.accent-fb-primary{accent-color:#0ea5e9}.opacity-0{opacity:0}.opacity-20{opacity:.2}.opacity-40{opacity:.4}.opacity-5{opacity:.05}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.shadow{--tw-shadow:0 1px 3px 0 rgba(0,0,0,.1),0 1px 2px -1px rgba(0,0,0,.1);--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color)}.shadow,.shadow-2xl{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-2xl{--tw-shadow:0 25px 50px -12px rgba(0,0,0,.25);--tw-shadow-colored:0 25px 50px -12px var(--tw-shadow-color)}.shadow-\[0_0_10px_3px_rgba\(52\2c 211\2c 153\2c 0\.95\)\]{--tw-shadow:0 0 10px 3px rgba(52,211,153,.95);--tw-shadow-colored:0 0 10px 3px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(192\2c 132\2c 252\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(192,132,252,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(192\2c 132\2c 252\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(192,132,252,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(216\2c 180\2c 254\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(216,180,254,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(221\2c 214\2c 254\2c 0\.6\)\]{--tw-shadow:0 0 12px rgba(221,214,254,.6);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(251\2c 146\2c 60\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(251,146,60,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(251\2c 146\2c 60\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(251,146,60,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(96\2c 165\2c 250\2c 0\.55\)\]{--tw-shadow:0 0 12px rgba(96,165,250,.55);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_12px_rgba\(96\2c 165\2c 250\2c 0\.65\)\]{--tw-shadow:0 0 12px rgba(96,165,250,.65);--tw-shadow-colored:0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_4px_1px_rgba\(185\2c 28\2c 28\2c 0\.7\)\]{--tw-shadow:0 0 4px 1px rgba(185,28,28,.7);--tw-shadow-colored:0 0 4px 1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_6px_2px_rgba\(239\2c 68\2c 68\2c 0\.8\)\]{--tw-shadow:0 0 6px 2px rgba(239,68,68,.8);--tw-shadow-colored:0 0 6px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_6px_2px_rgba\(249\2c 115\2c 22\2c 0\.85\)\]{--tw-shadow:0 0 6px 2px rgba(249,115,22,.85);--tw-shadow-colored:0 0 6px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_2px_rgba\(234\2c 179\2c 8\2c 0\.9\)\]{--tw-shadow:0 0 8px 2px rgba(234,179,8,.9);--tw-shadow-colored:0 0 8px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_2px_rgba\(52\2c 211\2c 153\2c 0\.85\)\]{--tw-shadow:0 0 8px 2px rgba(52,211,153,.85);--tw-shadow-colored:0 0 8px 2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(255\2c 255\2c 255\2c 0\.5\)\]{--tw-shadow:0 0 8px hsla(0,0%,100%,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_8px_rgba\(74\2c 222\2c 128\2c 0\.5\)\]{--tw-shadow:0 0 8px rgba(74,222,128,.5);--tw-shadow-colored:0 0 8px var(--tw-shadow-color)}.shadow-\[0_0_8px_rgba\(74\2c 222\2c 128\2c 0\.5\)\],.shadow-lg{box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px rgba(0,0,0,.1),0 4px 6px -4px rgba(0,0,0,.1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color),0 4px 6px -4px var(--tw-shadow-color)}.shadow-xl{--tw-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);--tw-shadow-colored:0 20px 25px -5px var(--tw-shadow-color),0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.shadow-fb-primary\/20{--tw-shadow-color:rgba(14,165,233,.2);--tw-shadow:var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.\!ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)!important;--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)!important;box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)!important}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring,.ring-1{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-1{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.ring-amber-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 243 199/var(--tw-ring-opacity,1))}.ring-amber-200{--tw-ring-opacity:1;--tw-ring-color:rgb(253 230 138/var(--tw-ring-opacity,1))}.ring-amber-300{--tw-ring-opacity:1;--tw-ring-color:rgb(252 211 77/var(--tw-ring-opacity,1))}.ring-amber-400{--tw-ring-opacity:1;--tw-ring-color:rgb(251 191 36/var(--tw-ring-opacity,1))}.ring-amber-50{--tw-ring-opacity:1;--tw-ring-color:rgb(255 251 235/var(--tw-ring-opacity,1))}.ring-amber-500{--tw-ring-opacity:1;--tw-ring-color:rgb(245 158 11/var(--tw-ring-opacity,1))}.ring-amber-600{--tw-ring-opacity:1;--tw-ring-color:rgb(217 119 6/var(--tw-ring-opacity,1))}.ring-amber-700{--tw-ring-opacity:1;--tw-ring-color:rgb(180 83 9/var(--tw-ring-opacity,1))}.ring-amber-800{--tw-ring-opacity:1;--tw-ring-color:rgb(146 64 14/var(--tw-ring-opacity,1))}.ring-amber-900{--tw-ring-opacity:1;--tw-ring-color:rgb(120 53 15/var(--tw-ring-opacity,1))}.ring-blue-100{--tw-ring-opacity:1;--tw-ring-color:rgb(219 234 254/var(--tw-ring-opacity,1))}.ring-blue-200{--tw-ring-opacity:1;--tw-ring-color:rgb(191 219 254/var(--tw-ring-opacity,1))}.ring-blue-300{--tw-ring-opacity:1;--tw-ring-color:rgb(147 197 253/var(--tw-ring-opacity,1))}.ring-blue-400{--tw-ring-opacity:1;--tw-ring-color:rgb(96 165 250/var(--tw-ring-opacity,1))}.ring-blue-50{--tw-ring-opacity:1;--tw-ring-color:rgb(239 246 255/var(--tw-ring-opacity,1))}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.ring-blue-600{--tw-ring-opacity:1;--tw-ring-color:rgb(37 99 235/var(--tw-ring-opacity,1))}.ring-blue-700{--tw-ring-opacity:1;--tw-ring-color:rgb(29 78 216/var(--tw-ring-opacity,1))}.ring-blue-800{--tw-ring-opacity:1;--tw-ring-color:rgb(30 64 175/var(--tw-ring-opacity,1))}.ring-blue-900{--tw-ring-opacity:1;--tw-ring-color:rgb(30 58 138/var(--tw-ring-opacity,1))}.ring-cyan-500\/30{--tw-ring-color:rgba(6,182,212,.3)}.ring-emerald-400{--tw-ring-opacity:1;--tw-ring-color:rgb(52 211 153/var(--tw-ring-opacity,1))}.ring-fb-primary{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.ring-gray-100{--tw-ring-opacity:1;--tw-ring-color:rgb(243 244 246/var(--tw-ring-opacity,1))}.ring-gray-200{--tw-ring-opacity:1;--tw-ring-color:rgb(229 231 235/var(--tw-ring-opacity,1))}.ring-gray-300{--tw-ring-opacity:1;--tw-ring-color:rgb(209 213 219/var(--tw-ring-opacity,1))}.ring-gray-400{--tw-ring-opacity:1;--tw-ring-color:rgb(156 163 175/var(--tw-ring-opacity,1))}.ring-gray-50{--tw-ring-opacity:1;--tw-ring-color:rgb(249 250 251/var(--tw-ring-opacity,1))}.ring-gray-500{--tw-ring-opacity:1;--tw-ring-color:rgb(107 114 128/var(--tw-ring-opacity,1))}.ring-gray-600{--tw-ring-opacity:1;--tw-ring-color:rgb(75 85 99/var(--tw-ring-opacity,1))}.ring-gray-700{--tw-ring-opacity:1;--tw-ring-color:rgb(55 65 81/var(--tw-ring-opacity,1))}.ring-gray-800{--tw-ring-opacity:1;--tw-ring-color:rgb(31 41 55/var(--tw-ring-opacity,1))}.ring-gray-900{--tw-ring-opacity:1;--tw-ring-color:rgb(17 24 39/var(--tw-ring-opacity,1))}.ring-green-100{--tw-ring-opacity:1;--tw-ring-color:rgb(220 252 231/var(--tw-ring-opacity,1))}.ring-green-200{--tw-ring-opacity:1;--tw-ring-color:rgb(187 247 208/var(--tw-ring-opacity,1))}.ring-green-300{--tw-ring-opacity:1;--tw-ring-color:rgb(134 239 172/var(--tw-ring-opacity,1))}.ring-green-400{--tw-ring-opacity:1;--tw-ring-color:rgb(74 222 128/var(--tw-ring-opacity,1))}.ring-green-50{--tw-ring-opacity:1;--tw-ring-color:rgb(240 253 244/var(--tw-ring-opacity,1))}.ring-green-500{--tw-ring-opacity:1;--tw-ring-color:rgb(34 197 94/var(--tw-ring-opacity,1))}.ring-green-600{--tw-ring-opacity:1;--tw-ring-color:rgb(22 163 74/var(--tw-ring-opacity,1))}.ring-green-700{--tw-ring-opacity:1;--tw-ring-color:rgb(21 128 61/var(--tw-ring-opacity,1))}.ring-green-800{--tw-ring-opacity:1;--tw-ring-color:rgb(22 101 52/var(--tw-ring-opacity,1))}.ring-green-900{--tw-ring-opacity:1;--tw-ring-color:rgb(20 83 45/var(--tw-ring-opacity,1))}.ring-indigo-100{--tw-ring-opacity:1;--tw-ring-color:rgb(224 231 255/var(--tw-ring-opacity,1))}.ring-indigo-200{--tw-ring-opacity:1;--tw-ring-color:rgb(199 210 254/var(--tw-ring-opacity,1))}.ring-indigo-300{--tw-ring-opacity:1;--tw-ring-color:rgb(165 180 252/var(--tw-ring-opacity,1))}.ring-indigo-400{--tw-ring-opacity:1;--tw-ring-color:rgb(129 140 248/var(--tw-ring-opacity,1))}.ring-indigo-50{--tw-ring-opacity:1;--tw-ring-color:rgb(238 242 255/var(--tw-ring-opacity,1))}.ring-indigo-500{--tw-ring-opacity:1;--tw-ring-color:rgb(99 102 241/var(--tw-ring-opacity,1))}.ring-indigo-600{--tw-ring-opacity:1;--tw-ring-color:rgb(79 70 229/var(--tw-ring-opacity,1))}.ring-indigo-700{--tw-ring-opacity:1;--tw-ring-color:rgb(67 56 202/var(--tw-ring-opacity,1))}.ring-indigo-800{--tw-ring-opacity:1;--tw-ring-color:rgb(55 48 163/var(--tw-ring-opacity,1))}.ring-indigo-900{--tw-ring-opacity:1;--tw-ring-color:rgb(49 46 129/var(--tw-ring-opacity,1))}.ring-pink-100{--tw-ring-opacity:1;--tw-ring-color:rgb(252 231 243/var(--tw-ring-opacity,1))}.ring-pink-200{--tw-ring-opacity:1;--tw-ring-color:rgb(251 207 232/var(--tw-ring-opacity,1))}.ring-pink-300{--tw-ring-opacity:1;--tw-ring-color:rgb(249 168 212/var(--tw-ring-opacity,1))}.ring-pink-400{--tw-ring-opacity:1;--tw-ring-color:rgb(244 114 182/var(--tw-ring-opacity,1))}.ring-pink-50{--tw-ring-opacity:1;--tw-ring-color:rgb(253 242 248/var(--tw-ring-opacity,1))}.ring-pink-500{--tw-ring-opacity:1;--tw-ring-color:rgb(236 72 153/var(--tw-ring-opacity,1))}.ring-pink-600{--tw-ring-opacity:1;--tw-ring-color:rgb(219 39 119/var(--tw-ring-opacity,1))}.ring-pink-700{--tw-ring-opacity:1;--tw-ring-color:rgb(190 24 93/var(--tw-ring-opacity,1))}.ring-pink-800{--tw-ring-opacity:1;--tw-ring-color:rgb(157 23 77/var(--tw-ring-opacity,1))}.ring-pink-900{--tw-ring-opacity:1;--tw-ring-color:rgb(131 24 67/var(--tw-ring-opacity,1))}.ring-purple-100{--tw-ring-opacity:1;--tw-ring-color:rgb(243 232 255/var(--tw-ring-opacity,1))}.ring-purple-200{--tw-ring-opacity:1;--tw-ring-color:rgb(233 213 255/var(--tw-ring-opacity,1))}.ring-purple-300{--tw-ring-opacity:1;--tw-ring-color:rgb(216 180 254/var(--tw-ring-opacity,1))}.ring-purple-400{--tw-ring-opacity:1;--tw-ring-color:rgb(192 132 252/var(--tw-ring-opacity,1))}.ring-purple-50{--tw-ring-opacity:1;--tw-ring-color:rgb(250 245 255/var(--tw-ring-opacity,1))}.ring-purple-500{--tw-ring-opacity:1;--tw-ring-color:rgb(168 85 247/var(--tw-ring-opacity,1))}.ring-purple-600{--tw-ring-opacity:1;--tw-ring-color:rgb(147 51 234/var(--tw-ring-opacity,1))}.ring-purple-700{--tw-ring-opacity:1;--tw-ring-color:rgb(126 34 206/var(--tw-ring-opacity,1))}.ring-purple-800{--tw-ring-opacity:1;--tw-ring-color:rgb(107 33 168/var(--tw-ring-opacity,1))}.ring-purple-900{--tw-ring-opacity:1;--tw-ring-color:rgb(88 28 135/var(--tw-ring-opacity,1))}.ring-red-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 226 226/var(--tw-ring-opacity,1))}.ring-red-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 202 202/var(--tw-ring-opacity,1))}.ring-red-300{--tw-ring-opacity:1;--tw-ring-color:rgb(252 165 165/var(--tw-ring-opacity,1))}.ring-red-400{--tw-ring-opacity:1;--tw-ring-color:rgb(248 113 113/var(--tw-ring-opacity,1))}.ring-red-50{--tw-ring-opacity:1;--tw-ring-color:rgb(254 242 242/var(--tw-ring-opacity,1))}.ring-red-500{--tw-ring-opacity:1;--tw-ring-color:rgb(239 68 68/var(--tw-ring-opacity,1))}.ring-red-600{--tw-ring-opacity:1;--tw-ring-color:rgb(220 38 38/var(--tw-ring-opacity,1))}.ring-red-700{--tw-ring-opacity:1;--tw-ring-color:rgb(185 28 28/var(--tw-ring-opacity,1))}.ring-red-800{--tw-ring-opacity:1;--tw-ring-color:rgb(153 27 27/var(--tw-ring-opacity,1))}.ring-red-900{--tw-ring-opacity:1;--tw-ring-color:rgb(127 29 29/var(--tw-ring-opacity,1))}.ring-slate-100{--tw-ring-opacity:1;--tw-ring-color:rgb(241 245 249/var(--tw-ring-opacity,1))}.ring-slate-200{--tw-ring-opacity:1;--tw-ring-color:rgb(226 232 240/var(--tw-ring-opacity,1))}.ring-slate-300{--tw-ring-opacity:1;--tw-ring-color:rgb(203 213 225/var(--tw-ring-opacity,1))}.ring-slate-400{--tw-ring-opacity:1;--tw-ring-color:rgb(148 163 184/var(--tw-ring-opacity,1))}.ring-slate-50{--tw-ring-opacity:1;--tw-ring-color:rgb(248 250 252/var(--tw-ring-opacity,1))}.ring-slate-500{--tw-ring-opacity:1;--tw-ring-color:rgb(100 116 139/var(--tw-ring-opacity,1))}.ring-slate-600{--tw-ring-opacity:1;--tw-ring-color:rgb(71 85 105/var(--tw-ring-opacity,1))}.ring-slate-700{--tw-ring-opacity:1;--tw-ring-color:rgb(51 65 85/var(--tw-ring-opacity,1))}.ring-slate-800{--tw-ring-opacity:1;--tw-ring-color:rgb(30 41 59/var(--tw-ring-opacity,1))}.ring-slate-900{--tw-ring-opacity:1;--tw-ring-color:rgb(15 23 42/var(--tw-ring-opacity,1))}.ring-yellow-100{--tw-ring-opacity:1;--tw-ring-color:rgb(254 249 195/var(--tw-ring-opacity,1))}.ring-yellow-200{--tw-ring-opacity:1;--tw-ring-color:rgb(254 240 138/var(--tw-ring-opacity,1))}.ring-yellow-300{--tw-ring-opacity:1;--tw-ring-color:rgb(253 224 71/var(--tw-ring-opacity,1))}.ring-yellow-400{--tw-ring-opacity:1;--tw-ring-color:rgb(250 204 21/var(--tw-ring-opacity,1))}.ring-yellow-50{--tw-ring-opacity:1;--tw-ring-color:rgb(254 252 232/var(--tw-ring-opacity,1))}.ring-yellow-500{--tw-ring-opacity:1;--tw-ring-color:rgb(234 179 8/var(--tw-ring-opacity,1))}.ring-yellow-600{--tw-ring-opacity:1;--tw-ring-color:rgb(202 138 4/var(--tw-ring-opacity,1))}.ring-yellow-700{--tw-ring-opacity:1;--tw-ring-color:rgb(161 98 7/var(--tw-ring-opacity,1))}.ring-yellow-800{--tw-ring-opacity:1;--tw-ring-color:rgb(133 77 14/var(--tw-ring-opacity,1))}.ring-yellow-900{--tw-ring-opacity:1;--tw-ring-color:rgb(113 63 18/var(--tw-ring-opacity,1))}.blur{--tw-blur:blur(8px)}.blur,.drop-shadow{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow:drop-shadow(0 1px 2px rgba(0,0,0,.1)) drop-shadow(0 1px 1px rgba(0,0,0,.06))}.invert{--tw-invert:invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.\!filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)!important}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur:blur(8px)}.backdrop-blur,.backdrop-blur-lg{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-lg{--tw-backdrop-blur:blur(16px)}.backdrop-blur-md{--tw-backdrop-blur:blur(12px)}.backdrop-blur-md,.backdrop-blur-sm{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur:blur(4px)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-100{transition-duration:.1s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-4:after{content:var(--tw-content);height:1rem}.after\:w-4:after{content:var(--tw-content);width:1rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:border:after{content:var(--tw-content);border-width:1px}.after\:border-gray-300:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(209 213 219/var(--tw-border-opacity,1))}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity,1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content:"";content:var(--tw-content)}.first\:border-t-0:first-child{border-top-width:0}.first\:pt-0:first-child{padding-top:0}.last\:mb-0:last-child{margin-bottom:0}.last\:border-0:last-child{border-width:0}.hover\:scale-105:hover{--tw-scale-x:1.05;--tw-scale-y:1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-accent\/40:hover{border-color:rgba(64,128,224,.4)}.hover\:border-accent\/50:hover{border-color:rgba(64,128,224,.5)}.hover\:border-fb-border:hover{--tw-border-opacity:1;border-color:rgb(51 65 85/var(--tw-border-opacity,1))}.hover\:border-fb-border\/70:hover{border-color:rgba(51,65,85,.7)}.hover\:border-fb-primary:hover{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.hover\:border-fb-primary\/40:hover{border-color:rgba(14,165,233,.4)}.hover\:border-fb-primary\/50:hover{border-color:rgba(14,165,233,.5)}.hover\:border-gray-600:hover{--tw-border-opacity:1;border-color:rgb(75 85 99/var(--tw-border-opacity,1))}.hover\:border-red-700:hover{--tw-border-opacity:1;border-color:rgb(185 28 28/var(--tw-border-opacity,1))}.hover\:border-white:hover{--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.hover\:bg-accent-light:hover{--tw-bg-opacity:1;background-color:rgb(96 160 255/var(--tw-bg-opacity,1))}.hover\:bg-accent\/30:hover{background-color:rgba(64,128,224,.3)}.hover\:bg-black\/70:hover{background-color:rgba(0,0,0,.7)}.hover\:bg-black\/75:hover{background-color:rgba(0,0,0,.75)}.hover\:bg-dark-500:hover{--tw-bg-opacity:1;background-color:rgb(30 30 58/var(--tw-bg-opacity,1))}.hover\:bg-dark-700:hover{--tw-bg-opacity:1;background-color:rgb(16 16 30/var(--tw-bg-opacity,1))}.hover\:bg-dark-700\/70:hover{background-color:rgba(16,16,30,.7)}.hover\:bg-dark-800\/50:hover{background-color:rgba(10,10,18,.5)}.hover\:bg-dark-900:hover{--tw-bg-opacity:1;background-color:rgb(5 5 8/var(--tw-bg-opacity,1))}.hover\:bg-emerald-600:hover{--tw-bg-opacity:1;background-color:rgb(5 150 105/var(--tw-bg-opacity,1))}.hover\:bg-fb-border:hover{--tw-bg-opacity:1;background-color:rgb(51 65 85/var(--tw-bg-opacity,1))}.hover\:bg-fb-card:hover{--tw-bg-opacity:1;background-color:rgb(30 41 59/var(--tw-bg-opacity,1))}.hover\:bg-fb-card\/50:hover{background-color:rgba(30,41,59,.5)}.hover\:bg-fb-card\/60:hover{background-color:rgba(30,41,59,.6)}.hover\:bg-fb-card\/70:hover{background-color:rgba(30,41,59,.7)}.hover\:bg-fb-primary:hover{--tw-bg-opacity:1;background-color:rgb(14 165 233/var(--tw-bg-opacity,1))}.hover\:bg-fb-primaryHi:hover{--tw-bg-opacity:1;background-color:rgb(56 189 248/var(--tw-bg-opacity,1))}.hover\:bg-gold\/20:hover{background-color:rgba(232,192,64,.2)}.hover\:bg-green-900\/50:hover{background-color:rgba(20,83,45,.5)}.hover\:bg-purple-900\/60:hover{background-color:rgba(88,28,135,.6)}.hover\:bg-red-500:hover{--tw-bg-opacity:1;background-color:rgb(239 68 68/var(--tw-bg-opacity,1))}.hover\:bg-red-500\/10:hover{background-color:rgba(239,68,68,.1)}.hover\:bg-red-900\/30:hover{background-color:rgba(127,29,29,.3)}.hover\:bg-red-900\/50:hover{background-color:rgba(127,29,29,.5)}.hover\:bg-red-900\/60:hover{background-color:rgba(127,29,29,.6)}.hover\:bg-white\/5:hover{background-color:hsla(0,0%,100%,.05)}.hover\:text-accent:hover{--tw-text-opacity:1;color:rgb(64 128 224/var(--tw-text-opacity,1))}.hover\:text-accent-light:hover{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.hover\:text-fb-accent:hover{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity,1))}.hover\:text-fb-primaryHi:hover{--tw-text-opacity:1;color:rgb(56 189 248/var(--tw-text-opacity,1))}.hover\:text-fb-text:hover{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.hover\:text-gray-200:hover{--tw-text-opacity:1;color:rgb(229 231 235/var(--tw-text-opacity,1))}.hover\:text-gray-400:hover{--tw-text-opacity:1;color:rgb(156 163 175/var(--tw-text-opacity,1))}.hover\:text-red-100:hover{--tw-text-opacity:1;color:rgb(254 226 226/var(--tw-text-opacity,1))}.hover\:text-red-200:hover{--tw-text-opacity:1;color:rgb(254 202 202/var(--tw-text-opacity,1))}.hover\:text-red-300:hover{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity,1))}.hover\:text-red-400:hover{--tw-text-opacity:1;color:rgb(248 113 113/var(--tw-text-opacity,1))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.hover\:ring-1:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.hover\:ring-1:hover,.hover\:ring-2:hover{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.hover\:ring-2:hover{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.hover\:ring-fb-primary\/40:hover{--tw-ring-color:rgba(14,165,233,.4)}.hover\:ring-fb-primary\/50:hover{--tw-ring-color:rgba(14,165,233,.5)}.focus\:border-accent:focus{--tw-border-opacity:1;border-color:rgb(64 128 224/var(--tw-border-opacity,1))}.focus\:border-accent\/50:focus{border-color:rgba(64,128,224,.5)}.focus\:border-blue-500:focus{--tw-border-opacity:1;border-color:rgb(59 130 246/var(--tw-border-opacity,1))}.focus\:border-fb-primary:focus{--tw-border-opacity:1;border-color:rgb(14 165 233/var(--tw-border-opacity,1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-1:focus,.focus\:ring-2:focus{box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color)}.focus\:ring-accent\/30:focus{--tw-ring-color:rgba(64,128,224,.3)}.focus\:ring-accent\/40:focus{--tw-ring-color:rgba(64,128,224,.4)}.focus\:ring-accent\/60:focus{--tw-ring-color:rgba(64,128,224,.6)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246/var(--tw-ring-opacity,1))}.focus\:ring-fb-primary:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.focus\:ring-gray-500\/40:focus{--tw-ring-color:hsla(220,9%,46%,.4)}.focus\:ring-red-400\/60:focus{--tw-ring-color:hsla(0,91%,71%,.6)}.focus\:ring-sky-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(14 165 233/var(--tw-ring-opacity,1))}.active\:scale-95:active{--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}.group[open] .group-open\:rotate-180{--tw-rotate:180deg}.group:hover .group-hover\:scale-105,.group[open] .group-open\:rotate-180{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-105{--tw-scale-x:1.05;--tw-scale-y:1.05}.group:hover .group-hover\:text-accent-light{--tw-text-opacity:1;color:rgb(96 160 255/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-fb-text{--tw-text-opacity:1;color:rgb(248 250 252/var(--tw-text-opacity,1))}.group:hover .group-hover\:text-white{--tw-text-opacity:1;color:rgb(255 255 255/var(--tw-text-opacity,1))}.group:hover .group-hover\:opacity-100{opacity:1}.group:hover .group-hover\:opacity-70{opacity:.7}.peer:checked~.peer-checked\:bg-accent{--tw-bg-opacity:1;background-color:rgb(64 128 224/var(--tw-bg-opacity,1))}.peer:checked~.peer-checked\:after\:translate-x-full:after{content:var(--tw-content);--tw-translate-x:100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:after\:border-white:after{content:var(--tw-content);--tw-border-opacity:1;border-color:rgb(255 255 255/var(--tw-border-opacity,1))}.peer:focus~.peer-focus\:outline-none{outline:2px solid transparent;outline-offset:2px}@media (min-width:640px){.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:w-96{width:24rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.sm\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:justify-start{justify-content:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:text-left{text-align:left}.sm\:text-right{text-align:right}}@media (min-width:768px){.md\:col-span-2{grid-column:span 2/span 2}.md\:-mx-8{margin-left:-2rem;margin-right:-2rem}.md\:flex{display:flex}.md\:hidden{display:none}.md\:w-60{width:15rem}.md\:w-auto{width:auto}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-end{align-items:flex-end}.md\:items-center{align-items:center}.md\:p-8{padding:2rem}.md\:px-8{padding-left:2rem;padding-right:2rem}.md\:text-3xl{font-size:1.875rem;line-height:2.25rem}}@media (min-width:1024px){.lg\:col-span-2{grid-column:span 2/span 2}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:w-96{width:24rem}.lg\:max-w-96{max-width:24rem}.lg\:flex-none{flex:none}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.lg\:grid-cols-\[minmax\(9rem\2c 0\.9fr\)_minmax\(12rem\2c 1fr\)_4rem_minmax\(12rem\2c 1fr\)\]{grid-template-columns:minmax(9rem,.9fr) minmax(12rem,1fr) 4rem minmax(12rem,1fr)}.lg\:items-center{align-items:center}.lg\:items-stretch{align-items:stretch}.lg\:justify-end{justify-content:flex-end}.lg\:justify-between{justify-content:space-between}.lg\:gap-16{gap:4rem}}@media (min-width:1280px){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-6{grid-template-columns:repeat(6,minmax(0,1fr))}.xl\:gap-24{gap:6rem}} \ No newline at end of file