diff --git a/.gitignore b/.gitignore index cb8df27..330114e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,8 @@ __pycache__/ node_modules/ tests/__pycache__/ tests/plugins/__pycache__/ +dev/data/ +dev/artifacts/ +!dev/config.yaml +/data/revbank.buttons.json +/data/revbank.buttons.json.* diff --git a/Makefile b/Makefile index b4301cd..ee25bec 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PYTHON ?= python3 NPM ?= npm PHP ?= php -PY_FILES := kassa.py load_printer_logo.py $(shell find plugins tests -type f -name '*.py' | sort) +PY_FILES := kassa.py epc_payment.py load_printer_logo.py pos_sound_settings.py button_themes.py $(shell find plugins tests -type f -name '*.py' | sort) PHP_FILES := $(shell find www -type f -name '*.php' | sort) .PHONY: test python-test js-test php-test venv pip-compile pip-sync pip-sync-dev lint python-lint js-lint php-lint check-types fix check @@ -42,7 +42,7 @@ pip-sync-dev: ## synchronizes the .venv with the state of requirements.txt lint: python-lint js-lint php-lint ## Do basic linting python-lint: venv - @. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py load_printer_logo.py plugins + @. .venv/bin/activate && ${env} ${PYTHON} -m pylint --persistent=no kassa.py epc_payment.py load_printer_logo.py pos_sound_settings.py button_themes.py plugins @. .venv/bin/activate; for file in ${PY_FILES}; do ${env} ${PYTHON} -m black --check --quiet --workers 1 "$$file" || exit $$?; done node_modules/.package-lock.json: package.json package-lock.json diff --git a/README.md b/README.md index 775f6af..db6cae2 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,24 @@ The hackbank consist of a php front-end that communicates with a python back-end over mqtt. +## Deposit bank transfers + +After checking out a deposit to an account, the screen displays a large EPC QR +code with the deposited amount and the account's username as the transfer +description. The recipient defaults to Hack42, IBAN `NL43ABNA0136777643`; +override `deposit.epc.name` and `deposit.epc.iban` in `config.yaml` if needed. +The payment details are also displayed as text. QR images are generated locally. + +The dialog is a payment request, not a bank confirmation. Deposits are credited +using the existing checkout flow; bank payments are not automatically verified. +Closing the dialog or entering the next command clears the request. Startup +also clears retained requests. Cash checkouts do not produce a transfer code. +Multiple deposit lines for the same user are combined; purchases do not reduce +the requested deposit amount. Amounts must be whole cents. + +The payload follows [EPC069-12](https://www.europeanpaymentscouncil.eu/document-library/guidance-documents/quick-response-code-guidelines-enable-data-capture-initiation). +Support for scanning EPC codes depends on the banking app. + ## Development set-up You need python, paho-mqtt-client and apache+php. @@ -67,9 +85,287 @@ To get started, copy the files from `fakedata`. ### docker-compose +For the self-contained UI development setup at **http://localhost:4123**: + +```sh +docker compose -f compose.dev.yaml up -d --build +``` + +This runs PHP, MQTT, the unchanged Python backend, and a demo audio controller +in project-specific containers. The UI assets are mounted from `www`, so edits appear on refresh. +Sample members, balances, products, and stock are seeded once in the ignored +`dev/data` directory. This setup does not use production data or hardware; +the door command targets the development broker and no printer is connected. +All CSS, icons, fonts, and JavaScript are served locally, with no CDN or external +font service. + +Member and user accounts use 84px-tall buttons, 50% taller than the original +compact rows, with page tabs when needed. At 1920×1080 a page holds 42 accounts +(six columns and seven rows); smaller windows calculate their capacity from +the available space. Cash and amount +controls remain above the grid, and the receipt and command bar stay visible. +Filtering keeps the same button sizes, expands results on backspace, and +restores the previous selection and page when cleared. Typing and pasting +both update the filter; Tab still completes a unique match. +Products and commands retain pages of up to 56 buttons (8×7) at 1920×1080, +or 25 buttons on smaller screens. The command bar's Enter button submits the +same command as pressing Enter on the keyboard. +After one minute without keyboard, pointer, touch, wheel or form activity, the +command line regains focus if it has lost it. This preserves unsubmitted text, +the current screen and receipt, and sends no command. Music, Home Assistant +and open dialogs keep their focus; hidden or unfocused browser windows are +left alone. Activity inside the cross-origin embedded pages cannot be observed +by the bar, so their selected views are excluded until the user leaves them. + +The header identifies the venue as Lovelace Bar, with Drinks @ Hack42 below it. +The log strip is labelled HISTORY and the receipt footer reads +Eat. Drink. Hack. Repeat. +The larger Members/Guests buttons and page tabs support touch selection. +Commands are grouped into Products, Users, Sales, Wholesale, Market and Misc, +with an Abort action and a Settings section. Category tabs and headings have +emoji icons. All commands stacks the groups vertically, with four buttons per +row on the bar display; category tabs show +each group separately. Manage members contains member administration; +all underlying commands remain available. Remove item edits the current +unbilled sale; transaction undo is a separate action. Door open retains the +existing configured door command. + +Settings offers 🎉 Party mode, Dark, Banana, Dank Hit and Confetti modes. +Party mode reflects backend state and can be disabled even while another question is pending. +A missing printer no longer prevents exit: settlement totals and their history +are saved in the party state before attempting to print. No sale or account +balance is cleared by changing this setting. + +Appearance settings apply to this browser and survive reloads. The command bar +also accepts `darkmodeon` / `darkmodeoff`, `bananamodeon` / `bananamodeoff`, +`dankmodeon` / `dankmodeoff`, and `confettimodeon` / `confettimodeoff`. +While Banana mode is enabled, each click or tap adds another 40px-wide dancing +banana at a random screen location. Bananas let clicks +through, fade during their final two seconds, and disappear individually after +one minute; refreshing preserves their deadlines. Switching the mode off +clears all bananas. Dank Hit mode plays the supplied hitmarker sample through +the POS browser audio and displays a click-through marker for 100ms at each click or tap. +Confetti mode drops a small shower from the top for every submitted command, +and a larger shower after a completed sale. Particles fall off the bottom and +let clicks through. The local canvas renderer stops when no particles remain; +switching the setting off clears them immediately. Refunds, deposits, transfers +and undo do not trigger the larger sale shower, and reconnecting does not replay it. + +Members and Guests lists keep their Archive tab at the far right, after the +page buttons, including when there are multiple pages. +Archives list accounts whose last purchase is older than a year, in alphabetical order. +New accounts with no purchase record use their last account update as a conservative fallback. +A read-only index of +the existing checkout log is shared between sessions, reuses unchanged results, +and reads only appended data after the first scan. Initial reads are capped +at 8MiB. If that window cannot establish a full year's coverage, archives stay +unavailable and accounts remain in the normal lists. Deposits, transfers, +refunds and undo lines are excluded from purchase history. + +Clicking a user with an empty sale shows the balance and up to ten recent +purchase line items, alongside OK, undo, restore, receipt and cash drawer controls. +Every visit receives a fresh profile response, even when its data is unchanged. +The user page also offers Promote to guest or Promote to Member, using +the existing `delmember ` / `addmember ` commands. The action uses +actual membership, including while party mode changes the visible member list. +After checkout, the same view combines the transaction summary with the updated +profile and purchase history. Add again uses the current product price and only +adds to the sale, leaving the normal account selection and checkout flow intact. +Reordering is offered only when a historical description identifies one +current product. Older or ambiguous products remain visible as history. +Undo latest transaction appears only for an eligible latest transaction; +the backend rechecks its ID and owner before using the existing undo flow. +The local seed adds stitch, BugBlue, Jos, nino and Anus, two archive examples, +and sample purchase history without resetting existing development balances. +Clicking anywhere in the Hack42 logo, title, and subtitle area sends Abort and +clears the command input. +The Sounds page starts with the soundboard buttons, followed by the two volume +sliders, then the POS sound settings. The Sounds menu and command search omit +Deur off/on, Groove Salad, Jazz Radio +Blues, Kill Music, Louder, Quieter and Kassa off/on. The Sounds page also omits +Abort and Christmas; the header reset remains available. Ding Dong, Vergaderen, +Vergaderen Extreme, Throw and Yeet remain on that page after playback, without +the usual command confirmation beep. These sounds also work as typed commands. +Sounds provides two large +volume sliders for HackBank POS effects and Space audio. Each reads the current +output volume, applies changes, and refreshes every two seconds while open. +Opening Sounds is silent. Releasing a slider plays a short preview beep on +that output after saving its volume; dragging and automatic refreshes are +silent. At zero volume the output remains muted and no preview plays. +The development audio controller uses in-memory demo levels and never connects +to a sound device; it also simulates preview requests. + +Soundboard samples live in `www/sounds/soundboard/`. POS effects live in +`www/sounds/POS//`, with a separate choice for each feature: + +| Feature | Default sound | +| --- | --- | +| Command | `KDE_Beep_ClassicBeep.wav` | +| Deposit | `itsgone.wav` | +| Insufficient funds | `sinterklaas.wav` | +| Sale | `katsjing.wav` | +| Error | `KDE_Beep_Beep.wav` | + +Each dropdown lists audio files from its own feature folder. Changing a choice +immediately saves it in `data/revbank.sounds.json`, shares it with the other +browser sessions, then previews the selected sample. The Preview button plays +the current choice without changing it. Reset restores one feature's default; +Reset all to defaults restores all five and previews the command default. +Opening the page or receiving settings from another browser stays silent. +Settings requests use a separate route so they cannot answer a pending POS +question. The error alert retains its two repetitions for unrecognised input. + +The **😎 / 🙂** button sits immediately to the right of Members Archive or Guests +Archive. It toggles personal button styling for this browser: off gives user +buttons standard colours and lettering, hides custom icons and backdrops, +and pauses their shader rendering. Names, balances and button actions still +work normally. The preference survives reloads and follows other tabs in the +same browser; saved user designs stay intact. Studio previews continue to +show the designs while editing. +Sunglasses mean styles are on; a normal smile means styles are off. + +The **Button studio** action on each user profile opens “Hack42 Magic Button +Studio (tm)”. Current and draft buttons remain visible beside the editing +tools. Backgrounds support solid paint, 2–6 gradient stops with percentages +and a full 360° angle, glitter, and plasma. Effect speed and intensity are +adjustable. Lettering has colour or a separate gradient, six system font +stacks and the bundled OCR-A font **Cyber**, size, weight, a fixed angle and +optional continuous rotation. Icons can be built-in symbols or a personal image, +with size and rotation controls. +Lettering also has an optional label backdrop with its own colour, opacity, +padding and corner rounding. It follows text rotation and supports gradient +letters. **Name case** offers Original, UPPERCASE, lowercase, Capitalized Words, +camelCase, PascalCase, snake_case and kebab-case. Word-based styles recognise +spaces, underscores, hyphens, camel-case and acronym boundaries: `BugBlue` +becomes `bugBlue`, and `HTTPServer` becomes `httpServer` in camelCase. +These change only the button's visible name; account IDs, search and commands +keep the original username. Turning personal styles off restores original +spelling; Studio previews still show the selected design. Older saved designs +continue to load with the backdrop off and Original casing. +Cyber's font source and notices are in [`www/fonts/README.md`](www/fonts/README.md). +The native colour picker and six-digit hex fields work together. +Large scroll buttons also work on the bar's touch screen without swipe gestures. + +**Pick your kind of magic** includes ten animated shader presets alongside +the other backgrounds: Five lights, Snow, Cubes, Synthwave, Wonderful, +Heartfelt, Star Nest, 2D Clouds, Universe Within and Matrix. +Matrix has falling green code on black, with bright heads and fading trails; +its procedural glyphs need no textures. Its colours, speed and intensity are +editable, and it can be combined with any font or name case. +Cubes rotate in a wave from bottom left to top right; Synthwave drives through +a landscape of wireframe hills. Tap a sample or choose it under Background style, then +adjust its scene colours, effect cycle (longer is slower) and intensity. +The first four are original, texture-free shaders inspired by the supplied +[lights](https://glslsandbox.com/e#109700.0), +[snow](https://glslsandbox.com/e#109692.0), +[cubes](https://www.shadertoy.com/view/MdVXzw) and +[synthwave](https://www.shadertoy.com/view/tsScRK) references. +Five presets adapt the GLSL supplied by the user; their source credits, +individual licence notices and adaptation details are in +[`www/shaders/README.md`](www/shaders/README.md). +Heartfelt uses a procedural palette backdrop instead of a missing image; +Heartfelt and Universe Within run silently without audio inputs or long +intro/outro blackouts. No remote assets or third-party runtime libraries load. + +The local WebGL 1 renderer shares one context across every button and preview, +batches identical scenes for the same user, caps its internal image at 320 × 128 pixels and +runs at up to 24 frames per second. Hidden/off-screen buttons pause; opening +the studio pauses buttons behind the dialog. Reduced-motion mode uses a +still scene. If WebGL is unavailable or its context is lost, the saved colour +gradient stays visible and names, balances and button actions keep working. +Shader choices use the existing private button design storage, with the same +version-1 schema and a fixed allowlist of scene names. +Animation phases include a stable hash of the username, so people sharing a +preset move differently. Studio samples and the draft use the current user's +seed. Selecting another preset keeps the gallery canvases and draft canvas +alive; the small shared render buffer is preserved between copies to avoid +empty frames on Firefox graphics backends. +Repeated scan/selection updates retain an unchanged account grid and its page. +Balances and debt colours update directly, preserving shader canvases, glitter, +plasma, and text/icon rotations. Changed account sets or grid capacities still +rebuild the list; contextual command controls update on every response. + +Upload a PNG, JPEG, WebP or GIF (up to 8 MB), drag it into the icon area, or +paste an image with Ctrl+V / ⌘V while the dialog is focused. The browser crops +it to a square and resizes it to a 144 × 144 PNG; GIFs become still icons. +The original upload is not stored. Images use `object-fit: cover`. + +**Use my new button** saves to `data/revbank.buttons.json`, using an atomic +write. Designs and their embedded icons survive backend/browser restarts and +are shared across the bar's terminals through retained MQTT messages. This +file belongs to the installation's private runtime data and is ignored by +the public application repository. Include it in private data backups. +Cancel or Escape discards the draft; Revert changes restores the saved design, +and Start fresh restores the account's initial design as an unsaved draft. + +Initial designs keep stitch’s blue and bold white text (without a bundled +character image), BugBlue’s glitter, Jos’s key, nino’s blue/yellow flag and +Anus’s plasma. Personal icons must be supplied locally through the studio. +All effects are clipped inside their own button; balance badges keep their +debt colours. Animation respects reduced-motion settings. Saving a design +uses its own `button_studio` route, so it cannot answer a pending sale prompt +or change receipts, account names or balances. Only validated presentation +settings and small PNG data URLs are accepted; no arbitrary CSS or remote +image/font URLs are stored. No new external runtime dependencies are needed. + +Home Assistant and Music open directly in the left-hand iframe with rounded +top corners, square bottom corners and no extra toolbar. Music uses the LAN +sound-system interface at +`http://192.168.142.157:3000/`. +The selected Music tab matches Trollibox orange, while Home Assistant uses +its [primary blue](https://contributors.home-assistant.io/design/foundations/color/). +Both use dark text and icons for contrast. +The [supplied Hack42 logo](https://johnkr.com/wp-content/uploads/sites/2/2015/12/Hack42.png) +is already included as `www/images/Hack42.png` and is now displayed in the header. + +To stop this setup, run `docker compose -f compose.dev.yaml down`. To reset +the sample data, stop the containers, remove `dev/data`, and start them again. + +The original development setup remains available: + If you don't want to install apache and an mqtt server yourself you can use docker-compose for local development. Simply running `docker-compose up` will start an mqtt server on port 1883, an apache+php server serving the files in 'www' on port 4242, and hook them up to each other. All that's left is to run `kassa.py` and point your browser at http://localhost:4242 + +### Production audio controls + +`audio_control.py` uses the system's existing `pactl` command to read and adjust +only `hackbank_pos` (POS effects) and `snapcast` (Space audio). Previews use +the existing `paplay` command and the local +`www/sounds/POS/command/KDE_Beep_ClassicBeep.wav` file, +explicitly selecting the adjusted output at full stream gain so its sink +volume determines the preview level. It does not change +the existing audio routing. `www/audio.php` connects to the controller through +`/run/hackbank-audio/control.sock`; the controller does not open a network port. +Volumes range from 0 to 100 percent; zero also mutes, and a nonzero setting +unmutes that output. Missing devices or an unavailable controller disable the +affected sliders. Financial commands continue through the existing backend. + +The supplied systemd unit targets the bar installation at `/home/barapp/kassa`, +runs as kiosk user `bar`, and gives Apache's `www` group access to the socket. +After deploying the UI branch, register and start it as root: + +```sh +systemctl enable --now /home/barapp/kassa/deploy/hackbank-audio-control.service +systemctl status hackbank-audio-control.service +``` + +The unit depends on the existing kiosk PulseAudio/PipeWire socket and named +outputs. The page title remains `Hack42 - HackBank`, the identifier used by the +existing POS audio router. Restart `hackbank-audio-control.service` after +updating its Python controller. Changes to `plugins/sounds.py`, including the +silent Sounds navigation, also require restarting the existing POS backend. +The profile response fix in `kassa.py` and sale display events in +`plugins/historie.py` require the same backend restart. +Button studio deployments must also include `button_themes.py`, +`plugins/buttonstudio.py`, the JS/CSS files (including +`www/kassa-button-shaders.js`), the `www/fonts` directory and the updated PHP routes, +and restart the POS backend. Existing private button data must be preserved. +The sound-folder update also requires a backend restart and an audio-controller +restart; include `pos_sound_settings.py` and the entire `www/sounds` tree when +deploying. Preserve the saved choices in the existing data directory. +For a different installation, adapt the unit's application path, kiosk user, +web-server group, and runtime audio socket before enabling it. diff --git a/audio_control.py b/audio_control.py new file mode 100644 index 0000000..715cd55 --- /dev/null +++ b/audio_control.py @@ -0,0 +1,213 @@ +"""Expose only the two HackBank output volumes over a local Unix socket.""" + +import argparse +from http.server import BaseHTTPRequestHandler +import json +import os +from pathlib import Path +import socketserver +import stat +import subprocess +import threading + +OUTPUTS = {"pos": "hackbank_pos", "space": "snapcast"} +PREVIEW_SOUND = ( + Path(__file__).resolve().parent / "www/sounds/POS/command/KDE_Beep_ClassicBeep.wav" +) + + +def validate_volume(channel, volume, preview=False): + if channel not in OUTPUTS: + raise ValueError("Unknown audio output") + if ( + not isinstance(volume, int) + or isinstance(volume, bool) + or not 0 <= volume <= 100 + ): + raise ValueError("Volume must be an integer from 0 to 100") + if not isinstance(preview, bool): + raise ValueError("Preview must be a boolean") + + +class PulseAudio: + def __init__(self, run=None, play=None): + self.run = run or self._run + self.play = play or self._play + + @staticmethod + def _run(*args): + return subprocess.run( + ["/usr/bin/pactl", *args], + check=True, + capture_output=True, + text=True, + timeout=4, + ).stdout + + @staticmethod + def _play(sink): + subprocess.run( + [ + "/usr/bin/paplay", + f"--device={sink}", + "--volume=65536", + "--client-name=HackBank volume preview", + "--stream-name=HackBank volume preview", + str(PREVIEW_SOUND), + ], + check=True, + capture_output=True, + timeout=4, + ) + + def read(self): + sinks = { + sink["name"]: sink + for sink in json.loads(self.run("-f", "json", "list", "sinks")) + } + channels = {} + for channel, name in OUTPUTS.items(): + sink = sinks.get(name) + values = ( + [entry["value"] for entry in sink.get("volume", {}).values()] + if sink + else [] + ) + channels[channel] = { + "available": bool(values), + "volume": ( + round(sum(values) / len(values) * 100 / 65536) if values else None + ), + "muted": bool(sink and sink.get("mute")), + } + return {"channels": channels} + + def set(self, channel, volume, preview=False): + validate_volume(channel, volume, preview) + if not self.read()["channels"][channel]["available"]: + raise RuntimeError("Audio output is unavailable") + self.run("set-sink-volume", OUTPUTS[channel], f"{volume}%") + self.run("set-sink-mute", OUTPUTS[channel], "1" if volume == 0 else "0") + state = self.read() + if preview: + state["preview_played"] = False + if volume > 0: + try: + self.play(OUTPUTS[channel]) + state["preview_played"] = True + except (OSError, subprocess.SubprocessError): + state["preview_error"] = True + return state + + +class DemoAudio: + """Development-only state; never connects to an audio device.""" + + def __init__(self): + self.volumes = {"pos": 35, "space": 70} + self.previews = [] + + def read(self): + return { + "channels": { + channel: {"available": True, "volume": volume, "muted": volume == 0} + for channel, volume in self.volumes.items() + } + } + + def set(self, channel, volume, preview=False): + validate_volume(channel, volume, preview) + self.volumes[channel] = volume + state = self.read() + if preview: + state["preview_played"] = volume > 0 + if volume > 0: + self.previews.append(channel) + return state + + +class AudioRequest(BaseHTTPRequestHandler): + def setup(self): + super().setup() + self.connection.settimeout(5) + + def address_string(self): + return "local" + + def _reply(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _dispatch(self, write=False): + if self.path != "/volume": + self._reply(404, {"error": "Unknown endpoint"}) + return + try: + with self.server.audio_lock: + if write: + if self.headers.get_content_type() != "application/json": + self._reply(415, {"error": "JSON required"}) + return + length = int(self.headers.get("Content-Length", "0")) + if not 0 < length <= 256: + raise ValueError("Invalid request length") + request = json.loads(self.rfile.read(length)) + if ( + not isinstance(request, dict) + or not {"channel", "volume"} <= set(request) + or set(request) - {"channel", "volume", "preview"} + ): + raise ValueError("Invalid volume request") + state = self.server.audio.set( + request["channel"], + request["volume"], + request.get("preview", False), + ) + else: + state = self.server.audio.read() + self._reply(200, state) + except (ValueError, TypeError, KeyError): + self._reply(400, {"error": "Invalid volume request"}) + except (OSError, RuntimeError, subprocess.SubprocessError): + self._reply(503, {"error": "Audio system is unavailable"}) + + def do_GET(self): # pylint: disable=invalid-name + self._dispatch() + + def do_POST(self): # pylint: disable=invalid-name + self._dispatch(write=True) + + +class AudioServer(socketserver.ThreadingUnixStreamServer): + daemon_threads = True + + def __init__(self, path, audio): + self.audio = audio + self.audio_lock = threading.Lock() + super().__init__(path, AudioRequest) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--socket", default="/run/hackbank-audio/control.sock") + parser.add_argument( + "--demo", action="store_true", help="Use in-memory development volumes" + ) + args = parser.parse_args() + path = Path(args.socket) + if path.exists(): + if not stat.S_ISSOCK(path.stat().st_mode): + raise RuntimeError("Audio socket path is occupied by a regular file") + path.unlink() + with AudioServer(str(path), DemoAudio() if args.demo else PulseAudio()) as server: + os.chmod(path, 0o660) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/button_themes.py b/button_themes.py new file mode 100644 index 0000000..f0f9a9a --- /dev/null +++ b/button_themes.py @@ -0,0 +1,210 @@ +"""Presentation-only button designs stored with the bar's private runtime data.""" + +import base64 +import binascii +import json +import os +from pathlib import Path +import re +import struct +import tempfile +import threading + +MAX_REQUEST = 160 * 1024 +MAX_ICON_BYTES = 96 * 1024 +FONTS = {"system", "rounded", "comic", "mono", "serif", "condensed", "cyber"} +TEXT_CASES = { + "original", + "uppercase", + "lowercase", + "capitalize", + "camel", + "pascal", + "snake", + "kebab", +} +ICONS = {"none", "image", "key", "star", "bolt", "heart", "smile"} + + +def fields(value, names): + if not isinstance(value, dict) or set(value) != set(names.split()): + raise ValueError("This button design has missing or unknown settings.") + return value + + +def number(value, low, high): + if type(value) not in (int, float) or not low <= value <= high: + raise ValueError("A button setting is outside its allowed range.") + return value + + +def choice(value, choices): + if not isinstance(value, str) or value not in choices: + raise ValueError("Unknown button style.") + return value + + +def color(value): + if not isinstance(value, str) or not re.fullmatch(r"#[0-9a-fA-F]{6}", value): + raise ValueError("Use a six-digit hex colour, such as #6088B0.") + return value.lower() + + +def gradient(value): + fields(value, "angle stops") + stops = value["stops"] + if not isinstance(stops, list) or not 2 <= len(stops) <= 6: + raise ValueError("A gradient needs between two and six colour stops.") + result = [] + for stop in stops: + fields(stop, "color position") + result.append( + { + "color": color(stop["color"]), + "position": number(stop["position"], 0, 100), + } + ) + return {"angle": number(value["angle"], 0, 360), "stops": result} + + +def icon_image(value): + if not isinstance(value, str): + raise ValueError("Choose an image for your icon.") + if not value: + return "" + prefix = "data:image/png;base64," + if not value.startswith(prefix) or len(value) > MAX_ICON_BYTES * 4 // 3 + len( + prefix + ): + raise ValueError("Icon images must be small PNG images.") + try: + raw = base64.b64decode(value[len(prefix) :], validate=True) + except (ValueError, binascii.Error) as error: + raise ValueError("This icon image could not be read.") from error + if ( + len(raw) < 45 + or not raw.startswith(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR") + or not raw.endswith(b"\x00\x00\x00\x00IEND\xaeB`\x82") + ): + raise ValueError("This icon image could not be read.") + width, height = struct.unpack(">II", raw[16:24]) + if not 1 <= width <= 144 or not 1 <= height <= 144: + raise ValueError("Icon images must be resized to at most 144 pixels.") + return value + + +def label_backdrop(value): + if value is None: + return None + fields(value, "color opacity padding radius") + return { + "color": color(value["color"]), + "opacity": number(value["opacity"], 0, 100), + "padding": number(value["padding"], 0, 12), + "radius": number(value["radius"], 0, 20), + } + + +def validate_theme(value): + fields(value, "version background text icon") + if ( + not isinstance(value["version"], int) + or isinstance(value["version"], bool) + or value["version"] != 1 + ): + raise ValueError("Unknown button design version.") + background = fields(value["background"], "kind color gradient speed intensity") + # Existing version-1 designs predate configurable label backdrops and casing. + text = value["text"] + if isinstance(text, dict): + text = {"backdrop": None, "case": "original", **text} + text = fields(text, "color gradient size font weight angle rotation backdrop case") + icon = fields(value["icon"], "kind image size rotation") + image = icon_image(icon["image"]) + kind = choice(icon["kind"], ICONS) + if kind == "image" and not image: + raise ValueError("Choose an image for your icon.") + return { + "version": 1, + "background": { + "kind": choice( + background["kind"], + { + "solid", + "gradient", + "glitter", + "plasma", + "shader-lights", + "shader-snow", + "shader-cubes", + "shader-synthwave", + "shader-wonderful", + "shader-heartfelt", + "shader-star-nest", + "shader-clouds", + "shader-universe", + "shader-matrix", + }, + ), + "color": color(background["color"]), + "gradient": gradient(background["gradient"]), + "speed": number(background["speed"], 2, 60), + "intensity": number(background["intensity"], 0, 100), + }, + "text": { + "color": color(text["color"]), + "gradient": ( + gradient(text["gradient"]) if text["gradient"] is not None else None + ), + "size": number(text["size"], 10, 48), + "font": choice(text["font"], FONTS), + "case": choice(text["case"], TEXT_CASES), + "weight": number(text["weight"], 400, 900), + "angle": number(text["angle"], 0, 360), + "rotation": number(text["rotation"], 0, 60), + "backdrop": label_backdrop(text["backdrop"]), + }, + "icon": { + "kind": kind, + "image": image if kind == "image" else "", + "size": number(icon["size"], 20, 64), + "rotation": number(icon["rotation"], 0, 60), + }, + } + + +class ButtonThemes: + write_lock = threading.Lock() + + def __init__(self, path="data/revbank.buttons.json"): + self.path = Path(path) + + def all(self): + try: + saved = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Saved button designs could not be read.") from error + if not isinstance(saved, dict): + raise ValueError("Saved button designs could not be read.") + return {name: validate_theme(theme) for name, theme in saved.items()} + + def save(self, user, theme): + theme = validate_theme(theme) + with self.write_lock: + saved = self.all() + saved[user] = theme + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=self.path.name + ".", dir=self.path.parent, text=True + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(saved, output, ensure_ascii=True) + output.write("\n") + os.replace(temporary, self.path) + except Exception: + Path(temporary).unlink(missing_ok=True) + raise + return theme diff --git a/compose.dev.yaml b/compose.dev.yaml new file mode 100644 index 0000000..eef9424 --- /dev/null +++ b/compose.dev.yaml @@ -0,0 +1,67 @@ +name: hackbank-ui + +services: + audio: + image: python:3.12-slim + volumes: + - ./audio_control.py:/app/audio_control.py:ro + - ./www/sounds/POS/command/KDE_Beep_ClassicBeep.wav:/app/www/sounds/POS/command/KDE_Beep_ClassicBeep.wav:ro + - audio-runtime:/run/hackbank-audio + command: ["python", "/app/audio_control.py", "--demo"] + # Match www-data in the PHP development container. + user: "0:33" + + mqtt: + image: eclipse-mosquitto:2 + volumes: + - ./dev/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro + healthcheck: + test: ["CMD", "mosquitto_pub", "-h", "localhost", "-t", "dev/health", "-m", "ok"] + interval: 2s + timeout: 2s + retries: 15 + + backend: + build: + context: . + dockerfile: dev/Dockerfile + working_dir: /app + volumes: + - ./kassa.py:/app/kassa.py:ro + - ./config.py:/app/config.py:ro + - ./input_validation.py:/app/input_validation.py:ro + - ./purchase_history.py:/app/purchase_history.py:ro + - ./pos_sound_settings.py:/app/pos_sound_settings.py:ro + - ./button_themes.py:/app/button_themes.py:ro + - ./epc_payment.py:/app/epc_payment.py:ro + - ./www/sounds:/app/www/sounds:ro + - ./plugins:/app/plugins:ro + - ./images:/app/images:ro + - ./fakedata:/app/fakedata:ro + - ./dev:/app/dev:ro + - ./dev/data:/app/data + environment: + KASSA_CONFIG: /app/dev/config.yaml + PYTHONDONTWRITEBYTECODE: "1" + command: ["sh", "/app/dev/start.sh"] + depends_on: + mqtt: + condition: service_healthy + + web: + image: php:8.3-apache + volumes: + - ./www:/var/www/html:ro + - audio-runtime:/run/hackbank-audio:ro + ports: + - "127.0.0.1:4123:80" + environment: + MQTT_HOST: mqtt + depends_on: + mqtt: + condition: service_healthy + audio: + condition: service_started + +volumes: + audio-runtime: diff --git a/config.py b/config.py index d1ddf43..b4f1e48 100644 --- a/config.py +++ b/config.py @@ -7,6 +7,9 @@ CONFIG_PATH = "config.yaml" DEFAULT_CONFIG = { + "deposit": { + "epc": {"name": "Hack42", "iban": "NL43ABNA0136777643"}, + }, "mqtt": { "host": "localhost", "port": 1883, diff --git a/config.yaml.example b/config.yaml.example index 3601400..cf175a9 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,6 +1,11 @@ # Copy this file to config.yaml and adjust local values. # config.yaml is intentionally git-ignored so local secrets/device settings are not overwritten. +deposit: + epc: + name: Hack42 + iban: NL43ABNA0136777643 + mqtt: host: localhost port: 1883 diff --git a/deploy/hackbank-audio-control.service b/deploy/hackbank-audio-control.service new file mode 100644 index 0000000..2520a5f --- /dev/null +++ b/deploy/hackbank-audio-control.service @@ -0,0 +1,25 @@ +[Unit] +Description=HackBank POS and Space audio volume controls +After=hackbank-audio-route.service snapserver-desktop-capture.service + +[Service] +Type=simple +User=bar +Group=www +Environment=XDG_RUNTIME_DIR=/run/user/1000 +Environment=PULSE_SERVER=unix:/run/user/1000/pulse/native +ExecStart=/usr/bin/python3 -B /home/barapp/kassa/audio_control.py +Restart=on-failure +RestartSec=3 +RuntimeDirectory=hackbank-audio +RuntimeDirectoryMode=0750 +UMask=0007 +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +PrivateTmp=yes +RestrictAddressFamilies=AF_UNIX +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target diff --git a/dev/Dockerfile b/dev/Dockerfile new file mode 100644 index 0000000..48641c3 --- /dev/null +++ b/dev/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.12-slim +RUN apt-get update && apt-get install -y --no-install-recommends git \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt diff --git a/dev/config.yaml b/dev/config.yaml new file mode 100644 index 0000000..33c2b48 --- /dev/null +++ b/dev/config.yaml @@ -0,0 +1,19 @@ +mqtt: + host: mqtt + port: 1883 + keepalive: 60 +door: + mqtt: + host: mqtt + port: 1883 + topic: dev/door +stickers: + printer: + host: localhost + port: 9100 +pos: + serial: + port: /dev/hackbank-no-printer + baudrate: 19200 +logging: + level: INFO diff --git a/dev/mosquitto.conf b/dev/mosquitto.conf new file mode 100644 index 0000000..0e3b61e --- /dev/null +++ b/dev/mosquitto.conf @@ -0,0 +1,3 @@ +listener 1883 +allow_anonymous true +persistence false diff --git a/dev/seed.py b/dev/seed.py new file mode 100644 index 0000000..b5c9915 --- /dev/null +++ b/dev/seed.py @@ -0,0 +1,88 @@ +"""Seed the isolated development data once. Never reads production data.""" + +from pathlib import Path +import shutil +from datetime import datetime, timedelta + +data = Path("data") +data.mkdir(exist_ok=True) +if not (data / "revbank.accounts").exists(): + for source in Path("fakedata").glob("revbank.*"): + shutil.copyfile(source, data / source.name) + + names = [ + "alice", "bob", "charlie", "dana", "eddie", "floris", "grace", "hugo", + "iris", "jules", "kim", "lars", "mara", "noor", "oscar", "pim", "quinn", + "raboof", "robin", "sam", "tess", "uma", "vincent", "wouter", "yara", "zoe", + ] + balances = [24.50, 8.75, -4.20, 42.00, 15.80, 31.25, 6.50, -18.50] + (data / "revbank.accounts").write_text( + "".join(f"{name:20} {balances[i % len(balances)]:+8.2f} 2026-10-16_12:00:00\n" + for i, name in enumerate(names)) + + "guest +12.50 2026-10-16_12:00:00\n", + encoding="utf-8", + ) + (data / "revbank.members").write_text("\n".join(names) + "\n", encoding="utf-8") + (data / "revbank.products").write_text( + """# Beer +clubmatebeer 2.50 Club-Mate beer +grolsch 1.50 Grolsch pilsner +ipa 3.25 India pale ale +radler 1.75 Radler 0.0% +weizen 2.75 Weizen +tripel 3.50 Belgian tripel +stout 3.00 Coffee stout +# Soft drinks +clubmate 2.00 Club-Mate +cola 1.25 Coca-Cola +colazero 1.25 Coca-Cola Zero +gingerbeer 2.25 Ginger beer +icetea 1.50 Ice tea +tonic 1.75 Tonic water +sparkling 1.00 Sparkling water +orangejuice 1.50 Orange juice +# Hot drinks +coffee 0.50 Coffee +espresso 0.75 Espresso +tea 0.50 Tea +hotchocolate 1.00 Hot chocolate +# Snacks +crisps 1.00 Paprika crisps +stroopwafel 0.75 Stroopwafel +chocolate 1.25 Chocolate bar +nuts 1.50 Salted peanuts +noodles 1.75 Instant noodles +pizza 4.50 Pizza margherita +""", + encoding="utf-8", + ) + (data / "revbank.stock").write_text( + "".join(f"{name} 42\n" for name in ["grolsch", "ipa", "clubmate", "cola", "coffee", "crisps"]), + encoding="utf-8", + ) + +# Add UI fixtures to existing development data without resetting balances. +theme_users = ["stitch", "BugBlue", "Jos", "nino", "Anus"] +existing = {line.split()[0] for line in (data / "revbank.accounts").read_text().splitlines() + if line.strip() and not line.startswith("#")} +now = datetime.now() +with (data / "revbank.accounts").open("a", encoding="utf-8") as output: + for index, user in enumerate(theme_users + ["archive_member", "archive_guest"]): + if user not in existing: + date = "2024-01-01_12:00:00" if user.startswith("archive_") else now.strftime("%Y-%m-%d_%H:%M:%S") + output.write(f"{user:20} {[42.0, -4.2, 12.5, 8.75, -18.5][index % 5]:+8.2f} {date}\n") +members = set((data / "revbank.members").read_text().split()) +with (data / "revbank.members").open("a", encoding="utf-8") as output: + for user in theme_users + ["archive_member"]: + if user not in members: + output.write(user + "\n") +marker = data / ".ui-purchase-fixtures-v1" +if not marker.exists(): + with (data / "revbank.log").open("a", encoding="utf-8") as output: + for user in ["archive_member", "archive_guest"]: + output.write(f"2024-01-01_12:00:00 CHECKOUT 400000000 {user} 1 * 1.25 LOSE EUR 1.25 # Coca-Cola\n") + for index in range(12): + date = now - timedelta(days=12-index) + transaction = int(date.timestamp())-1300000000 + output.write(f"{date:%Y-%m-%d_%H:%M:%S} CHECKOUT {transaction} Jos 1 * 2.00 LOSE EUR 2.00 # Club-Mate\n") + marker.write_text("Development UI sample purchases; not real transactions.\n") diff --git a/dev/start.sh b/dev/start.sh new file mode 100644 index 0000000..e18fec8 --- /dev/null +++ b/dev/start.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu +python dev/seed.py +if [ ! -d data/.git ]; then + git -C data init -q + git -C data config user.name "HackBank development" + git -C data config user.email "dev@localhost" + git -C data add . + git -C data commit -qm "Initial sample data" +fi +exec python -u kassa.py diff --git a/epc_payment.py b/epc_payment.py new file mode 100644 index 0000000..0e8579b --- /dev/null +++ b/epc_payment.py @@ -0,0 +1,64 @@ +"""EPC069-12 payment requests, rendered locally without external services.""" + +import base64 +from decimal import Decimal +from io import BytesIO +import re + +import pyqrcode + +from config import config_get + + +def create_payment(reference, amount): + settings = config_get("deposit", "epc", default={}) + name = settings["name"] + iban = settings["iban"].replace(" ", "").upper() + for value, limit in ((name, 70), (reference, 140)): + if not isinstance(value, str) or not 1 <= len(value) <= limit: + raise ValueError("Invalid EPC recipient or reference length") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError("EPC fields cannot contain control characters") + if not re.fullmatch(r"[A-Z]{2}[0-9]{2}[A-Z0-9]{11,30}", iban): + raise ValueError("Invalid EPC IBAN") + checksum = "".join( + str(ord(char) - 55) if char.isalpha() else char for char in iban[4:] + iban[:4] + ) + if int(checksum) % 97 != 1: + raise ValueError("Invalid EPC IBAN checksum") + amount = Decimal(str(amount)) + if not amount.is_finite() or not Decimal("0.01") <= amount <= Decimal( + "999999999.99" + ): + raise ValueError("Invalid EPC amount") + if amount != amount.quantize(Decimal("0.01")): + raise ValueError("EPC amount must be in whole cents") + amount_text = format(amount, ".2f") + payload = "\n".join( + [ + "BCD", + "002", + "1", + "SCT", + "", + name, + iban, + "EUR" + amount_text, + "", + "", + reference, + ] + ) + if len(payload.encode("utf-8")) > 331: + raise ValueError("EPC payload exceeds 331 bytes") + code = pyqrcode.create(payload.encode("utf-8"), error="M", mode="binary") + output = BytesIO() + code.png(output, scale=8, quiet_zone=4) + return { + "name": name, + "iban": iban, + "amount": amount_text, + "reference": reference, + "qr": "data:image/png;base64," + + base64.b64encode(output.getvalue()).decode("ascii"), + } diff --git a/kassa.py b/kassa.py index 5b99df9..0574b13 100755 --- a/kassa.py +++ b/kassa.py @@ -163,6 +163,21 @@ def input(self, text): self.send_message(True, "buttons", json.dumps({})) return + # Configuration must remain reachable while a product/user prompt is + # waiting for an answer, without aborting the pending sale or prompt. + if text.strip() in ("partymodeon", "partymodeoff") and "party" in self.plugins: + self.pre_input(text) + try: + self.plugins["party"].input(text.strip()) + except Exception: + logger.exception("Party configuration failed sid=%s", self.SID) + self.send_message( + True, "message", "Could not change party mode; please try again" + ) + if not self.nextcall: + self.send_message(True, "buttons", json.dumps({})) + return + self.buttons = {} self.pre_input(text) @@ -214,6 +229,8 @@ def send_message(self, retain, topic, message): or self.cache["hack42bar/output/session/" + self.SID + "/" + topic] != message or len(topic) < 8 + # A profile is a response to a new visit, even when its data is unchanged. + or (not retain and topic == "infobox/account") ): logger.debug( "send_message sid=%s retain=%s topic=%s message=%r", @@ -246,6 +263,12 @@ def run_session(client, SID, action, data): if action == "input": session = get_session(SID, client) session.input(data.decode()) + elif action == "sound_settings": + session = get_session(SID, client) + session.plugins["sounds"].configure(data) + elif action == "button_studio": + session = get_session(SID, client) + session.plugins["buttonstudio"].configure(data) else: logger.warning("unhandled_session_action sid=%s action=%s", SID, action) diff --git a/package.json b/package.json index 5b0e576..301f75a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kassa", "private": true, "scripts": { - "lint:js": "eslint www/kassa-textfill.js www/kassa-dom.js www/kassa-buttons.js www/kassa-stream.js www/kassa-app.js www/spaceconsole/all.js tests/js/*.test.js", + "lint:js": "eslint www/kassa-textfill.js www/kassa-dom.js www/kassa-buttons.js www/kassa-stream.js www/kassa-audio.js www/kassa-sound-settings.js www/kassa-confetti.js www/kassa-preferences.js www/shaders/*.js www/kassa-button-shaders.js www/kassa-button-styles.js www/kassa-button-studio.js www/kassa-idle-focus.js www/kassa-app.js www/spaceconsole/all.js tests/js/*.test.js", "test:js": "node --test tests/js/*.test.js" }, "devDependencies": { diff --git a/plugins/accounts.py b/plugins/accounts.py index 413ad97..f720682 100644 --- a/plugins/accounts.py +++ b/plugins/accounts.py @@ -374,9 +374,11 @@ def _resolve_account(self, text): def _use_account(self, account): if self.master.receipt.is_empty(): - self.master.send_message( - False, "infobox/account", json.dumps(self.accounts[account]) - ) + details = dict(self.accounts[account]) + plugins = getattr(self.master, "plugins", {}) + if isinstance(plugins, dict) and "historie" in plugins: + details.update(plugins["historie"].account_details(account)) + self.master.send_message(False, "infobox/account", json.dumps(details)) self.master.send_message( True, "buttons", json.dumps({"special": "infobox"}) ) diff --git a/plugins/buttonstudio.py b/plugins/buttonstudio.py new file mode 100644 index 0000000..7837c2e --- /dev/null +++ b/plugins/buttonstudio.py @@ -0,0 +1,69 @@ +"""Separate UI settings route: never consumes a POS command or pending question.""" + +import json +import logging +import re +from urllib.parse import quote + +from button_themes import ButtonThemes, MAX_REQUEST + +logger = logging.getLogger(__name__) + + +class buttonstudio: + def __init__(self, SID, master, store=None): + self.SID = SID + self.master = master + self.store = store or ButtonThemes() + + def help(self): + return {} + + def publish(self, user, theme): + self.master.client.publish( + "hack42bar/output/settings/buttons/" + quote(user, safe=""), + json.dumps({"user": user, "theme": theme}), + qos=1, + retain=True, + ) + + def startup(self): + for user, theme in self.store.all().items(): + self.publish(user, theme) + + def configure(self, payload): + request_id = "" + try: + if len(payload) > MAX_REQUEST: + raise ValueError("This button design is too large.") + request = json.loads(payload) + if not isinstance(request, dict): + raise ValueError("Invalid button request.") + request_id = request.get("request", "") + if not isinstance(request_id, str) or not re.fullmatch( + r"[A-Za-z0-9_-]{1,80}", request_id + ): + raise ValueError("Invalid button request.") + if set(request) not in ({"request", "user"}, {"request", "user", "theme"}): + raise ValueError("Invalid button request.") + user = request["user"] + if not isinstance(user, str) or user not in self.master.accounts.accounts: + raise ValueError("Choose an existing user for this button.") + if "theme" in request: + theme = self.store.save(user, request["theme"]) + else: + theme = self.store.all().get(user) + self.publish(user, theme) + reply = {"request": request_id, "ok": True, "user": user, "theme": theme} + except (ValueError, TypeError, OSError) as error: + logger.warning("button_studio_failed sid=%s error=%s", self.SID, error) + reply = { + "request": request_id, + "ok": False, + "error": ( + str(error) + if isinstance(error, ValueError) + else "Your button could not be saved. Please try again." + ), + } + self.master.send_message(False, "button_studio", json.dumps(reply)) diff --git a/plugins/deposit.py b/plugins/deposit.py index 63031c2..50a0e7b 100644 --- a/plugins/deposit.py +++ b/plugins/deposit.py @@ -1,10 +1,14 @@ import json +from decimal import Decimal, InvalidOperation + +from epc_payment import create_payment class deposit: def __init__(self, SID, master): self.master = master self.SID = SID + self.payment_shown = False def help(self): return {"deposit": "Deposit Money"} @@ -26,8 +30,8 @@ def value(self, text): self.master.callhook("abort", None) return True try: - value = float(text) - except ValueError: + amount = Decimal(text) + except InvalidOperation: self.master.donext(self, "value") self.master.send_message( True, "message", "Not a valid number! How much do you want to deposit?" @@ -36,8 +40,12 @@ def value(self, text): True, "buttons", json.dumps({"special": "numbers"}) ) return True - if 0 < value < 1000: - self.master.receipt.add(False, value, "Deposit", 1, None, "deposit") + if ( + amount.is_finite() + and Decimal("0.01") <= amount <= Decimal("999.99") + and amount == amount.quantize(Decimal("0.01")) + ): + self.master.receipt.add(False, float(amount), "Deposit", 1, None, "deposit") else: self.master.donext(self, "value") self.master.send_message( @@ -48,5 +56,27 @@ def value(self, text): ) return True + def hook_post_checkout(self, _user): + amounts = {} + for row in self.master.receipt.receipt: + user = row["beni"] + if row["product"] == "deposit" and not row["Lose"] and user != "cash": + amounts[user] = amounts.get(user, Decimal(0)) + ( + Decimal(str(row["value"])) * Decimal(str(row["count"])) + ) + if amounts: + payments = [ + create_payment(user, amount) for user, amount in amounts.items() + ] + self.master.send_message( + True, "deposit_payment", json.dumps({"payments": payments}) + ) + self.payment_shown = True + + def pre_input(self, _text): + if self.payment_shown: + self.master.send_message(True, "deposit_payment", "{}") + self.payment_shown = False + def startup(self): - pass + self.master.send_message(True, "deposit_payment", "{}") diff --git a/plugins/historie.py b/plugins/historie.py index 3c32f5a..6c28dc8 100644 --- a/plugins/historie.py +++ b/plugins/historie.py @@ -1,11 +1,13 @@ import json import os +from purchase_history import purchase_index class historie: def __init__(self, SID, master): self.master = master self.SID = SID + self.checkout = None def reverse_readline(self, filename, buf_size=8192): """a generator that returns the lines of a file in reverse order""" @@ -51,6 +53,67 @@ def reversesearch(self, text): def help(self): return {"history": "User History"} + def account_details(self, user): + products = self.master.products.products + purchases = purchase_index.recent(user) + for purchase in purchases: + matches = [ + name + for name, product in products.items() + if product["description"] == purchase["description"] + ] + if len(matches) == 1: + purchase["product"] = matches[0] + purchase["current_price"] = products[matches[0]]["price"] + undo = self.master.plugins.get("undo") + latest = undo.latest_for(user) if undo else None + return { + "user": user, + "is_member": user in self.master.accounts.members, + "purchases": purchases, + "history_available": purchase_index.available, + "history_complete": purchase_index.complete, + "undo_transaction": latest, + } + + def publish_activity(self): + self.master.send_message( + True, "activity", json.dumps(purchase_index.activity()) + ) + + def hook_endsession(self, _user): + self.publish_activity() + + def hook_post_checkout(self, user): + # Observe the receipt for display effects; accounting stays in its own hooks. + sale = any( + row["Lose"] + and row["count"] > 0 + and row["value"] > 0 + and row["product"] not in ("undo", "give", "take", "declaratie") + and not row["product"].startswith("pfand_") + for row in self.master.receipt.receipt + ) + self.checkout = {"user": user, "sale": sale} + + def hook_post_endsession(self, _user): + checkout, self.checkout = self.checkout, None + if not checkout: + return + user = checkout["user"] + if user in self.master.accounts.accounts: + details = { + **self.master.accounts.accounts[user], + **self.account_details(user), + "checkout": True, + } + self.master.send_message(False, "infobox/account", json.dumps(details)) + self.master.send_message( + True, "buttons", json.dumps({"special": "infobox"}) + ) + if checkout["sale"]: + self.master.send_message(False, "effects", json.dumps({"type": "sale"})) + def history(self, text): if text in self.master.accounts.accounts: lines = self.reversesearch(text) @@ -80,4 +143,4 @@ def input(self, text): return None def startup(self): - pass + self.publish_activity() diff --git a/plugins/party.py b/plugins/party.py index b46e6c2..fbf8ba3 100644 --- a/plugins/party.py +++ b/plugins/party.py @@ -44,6 +44,8 @@ def __init__(self, SID, master): self.started_amount = 0.0 self.credited_amount = 0.0 self.started_at = "" + self.last_settlement = None + self.settlements = [] def help(self): return { @@ -61,10 +63,18 @@ def loadstate(self): self.started_amount = 0.0 self.credited_amount = 0.0 self.started_at = "" + self.last_settlement = None + self.settlements = [] try: with open(STATE_FILE, encoding="utf-8") as f: data = json.load(f) self.active = bool(data.get("active", False)) + self.last_settlement = data.get("last_settlement") + self.settlements = data.get("settlements", []) + if not isinstance(self.settlements, list): + self.settlements = [] + if not self.settlements and isinstance(self.last_settlement, dict): + self.settlements.append(self.last_settlement) self.started_amount = float(data.get("started_amount", 0.0)) self.started_at = str(data.get("started_at", "")) if "credited_amount" in data: @@ -77,14 +87,24 @@ def loadstate(self): logger.warning("party_state_load_failed sid=%s error=%s", self.SID, exc) def writestate(self): + state = { + "active": self.active, + "started_amount": self.started_amount, + "credited_amount": self.credited_amount, + "started_at": self.started_at, + } + if self.last_settlement is not None: + state["last_settlement"] = self.last_settlement + if self.settlements: + state["settlements"] = self.settlements _atomic_write( STATE_FILE, - { - "active": self.active, - "started_amount": self.started_amount, - "credited_amount": self.credited_amount, - "started_at": self.started_at, - }, + state, + ) + + def publish_state(self): + self.master.send_message( + True, "settings/party", json.dumps({"enabled": self.active}) ) def _credited_amount_from_log(self): @@ -122,6 +142,7 @@ def _ensure_party_account(self): def _publish_members(self): self.master.accounts.publish_members() + self.publish_state() def _enable(self): self.loadstate() @@ -147,6 +168,7 @@ def _enable(self): def _disable(self): self.loadstate() if not self.active: + self.publish_state() self.master.send_message(True, "message", "Party mode is not on") return True self.master.accounts.readaccounts() @@ -161,6 +183,13 @@ def _disable(self): "settled_amount": settled_amount, "started_at": self.started_at, } + # A disconnected printer must never trap the register in party mode. + # Keep the settlement on disk so the totals survive a failed print. + self.last_settlement = settlement + self.settlements.append(settlement) + self.active = False + self.writestate() + self._publish_members() try: self.master.POS.printparty(settlement) except Exception as exc: # pylint: disable=broad-exception-caught @@ -168,13 +197,10 @@ def _disable(self): self.master.send_message( True, "message", - "Party mode receipt print failed: %s" % exc, + "Party mode off; settlement saved, receipt print failed: %s" % exc, ) return True - self.active = False - self.writestate() - self._publish_members() self.master.send_message( True, "message", @@ -205,6 +231,7 @@ def input(self, text): def startup(self): self.loadstate() + self.publish_state() if self.active: self.master.accounts.readaccounts() self._ensure_party_account() diff --git a/plugins/sounds.py b/plugins/sounds.py index 21cb0f4..48c0e11 100644 --- a/plugins/sounds.py +++ b/plugins/sounds.py @@ -1,11 +1,26 @@ # -*- coding: utf-8 -*- import json +import logging +import re +from pos_sound_settings import POSSoundSettings + +logger = logging.getLogger(__name__) + +SOUND_SAMPLES = { + "ns": "soundboard/ns.wav", + "vergaderen": "soundboard/vergaderen.mp3", + "vergaderenextreme": "soundboard/vergaderen_extreme.wav", + "vergaderen_extreme": "soundboard/vergaderen_extreme.wav", + "throw": "soundboard/throw.wav", + "yeet": "soundboard/yeet.wav", +} class sounds: - def __init__(self, SID, master): + def __init__(self, SID, master, settings=None): self.master = master self.SID = SID + self.settings = settings or POSSoundSettings() def help(self): return {"sounds": "All sound commands"} @@ -13,21 +28,24 @@ def help(self): def hook_checkout(self, _text): for r in self.master.receipt.receipt: if r["product"] == "deposit": - self.master.send_message(False, "sound", "itsgone.wav") + self.master.send_message( + False, "sound", self.settings.sample("deposit") + ) break - self.master.send_message(False, "sound", "katsjing.wav") + self.master.send_message(False, "sound", self.settings.sample("sale")) def hook_balance(self, args): _usr, _had, has, _transID = args if has < -13.37: - self.master.send_message(False, "sound", "sinterklaas.wav") + self.master.send_message( + False, "sound", self.settings.sample("insufficient_funds") + ) def hook_undo(self, args): _transID, _totals, _receipt, _beni = args def showsounds(self): custom = [] - custom.append({"text": "abort", "display": "Abort"}) custom.append({"text": "ns", "display": "Ding Dong"}) custom.append({"text": "deuron", "display": "Deur on"}) custom.append({"text": "deuroff", "display": "Deur off"}) @@ -35,11 +53,13 @@ def showsounds(self): custom.append({"text": "kassaoff", "display": "Kassa off"}) custom.append({"text": "killsounds", "display": "Kill Music"}) custom.append({"text": "groovesalad", "display": "Groove Salad"}) - custom.append({"text": "christmas", "display": "christmas"}) custom.append({"text": "blues", "display": "Jazz Radio Blues"}) custom.append({"text": "quieter", "display": "Quieter"}) custom.append({"text": "louder", "display": "Louder"}) custom.append({"text": "vergaderen", "display": "Vergaderen!"}) + custom.append({"text": "vergaderenextreme", "display": "Vergaderen Extreme"}) + custom.append({"text": "throw", "display": "Throw"}) + custom.append({"text": "yeet", "display": "Yeet"}) self.master.send_message( True, "buttons", json.dumps({"special": "custom", "custom": custom}) ) @@ -51,11 +71,8 @@ def input( if text == "sounds": self.showsounds() return True - if text == "ns": - self.master.send_message(False, "sound", "ns.wav") - return True - if text == "vergaderen": - self.master.send_message(False, "sound", "vergaderen.mp3") + if text in SOUND_SAMPLES: + self.master.send_message(False, "sound", SOUND_SAMPLES[text]) return True if text == "deuron": pass @@ -89,12 +106,86 @@ def input( return None def pre_input(self, text): - if text != "abort": - self.master.send_message(False, "sound", "KDE_Beep_ClassicBeep.wav") + command = text.strip() + if command not in ("abort", "sounds") and command not in SOUND_SAMPLES: + self.master.send_message(False, "sound", self.settings.sample("command")) def hook_wrong(self, _text): - self.master.send_message(False, "sound", "KDE_Beep_Beep.wav") - self.master.send_message(False, "sound", "KDE_Beep_Beep.wav") + self.master.send_message(False, "sound", self.settings.sample("error")) + self.master.send_message(False, "sound", self.settings.sample("error")) + + def publish_settings(self, state=None): + state = state or self.settings.state() + self.master.client.publish( + "hack42bar/output/settings/sounds", json.dumps(state), qos=1, retain=True + ) + + def configure(self, payload): + request_id = "" + try: + if len(payload) > 512: + raise ValueError("Invalid sound setting.") + request = json.loads(payload) + if not isinstance(request, dict): + raise ValueError("Invalid sound setting.") + request_id = request.get("request", "") + if not isinstance(request_id, str) or not re.fullmatch( + r"[A-Za-z0-9_-]{1,80}", request_id + ): + raise ValueError("Invalid sound request.") + preview = None + if set(request) == {"request"}: + state = self.settings.state() + elif set(request) == {"request", "feature", "sound"}: + state = self.settings.set(request["feature"], request["sound"]) + preview = request["feature"] + elif set(request) == {"request", "preview"}: + state = self.settings.state() + preview = request["preview"] + if not isinstance(preview, str) or not any( + item["id"] == preview for item in state["features"] + ): + raise ValueError("Unknown POS sound feature.") + elif set(request) == {"request", "reset"}: + feature = request["reset"] + if not isinstance(feature, str): + raise ValueError("Invalid sound feature.") + state = self.settings.reset(None if feature == "all" else feature) + preview = "command" if feature == "all" else feature + else: + raise ValueError("Invalid sound setting.") + self.publish_settings(state) + self.master.send_message( + False, + "sound_settings", + json.dumps( + { + "request": request_id, + "ok": True, + "settings": state, + } + ), + ) + if preview: + self.master.send_message(False, "sound", self.settings.sample(preview)) + except (ValueError, TypeError, OSError) as error: + logger.warning("sound_settings_failed sid=%s error=%s", self.SID, error) + message = ( + str(error) + if isinstance(error, ValueError) + else "Sound settings could not be saved. Please try again." + ) + self.master.send_message( + False, + "sound_settings", + json.dumps( + { + "request": request_id, + "ok": False, + "error": message, + } + ), + ) def startup(self): - pass + self.publish_settings() diff --git a/plugins/undo.py b/plugins/undo.py index fba5536..d8e5af4 100644 --- a/plugins/undo.py +++ b/plugins/undo.py @@ -21,6 +21,7 @@ def help(self): "undo": "Undo last transaction", "undolist": "Undo a transaction", "restore": "Restore a transaction", + "undouserlatest": "Undo user's latest transaction", } def hook_post_checkout(self, text): @@ -192,6 +193,12 @@ def listundo(self, restore=False): self.master.donext(self, "doundo") def input(self, text): + if text == "undouserlatest": + self.master.donext(self, "undo_user_latest") + self.master.send_message( + True, "message", "Select a user transaction to undo" + ) + return True if text == "undolist": self.listundo(0) return True @@ -204,6 +211,24 @@ def input(self, text): return True return None + def latest_for(self, user): + self.loadundo() + if not self.undo: + return None + latest = max(self.undo) + entry = self._get_undo_entry(latest) + return latest if entry and user in entry[0] else None + + def undo_user_latest(self, text): + transaction, separator, user = text.partition(":") + latest = self.latest_for(user) if separator else None + if latest is None or str(latest) != transaction: + self.master.send_message( + True, "message", "Latest transaction changed; reopen the user details" + ) + return True + return self.doundo(latest) + def hook_abort(self, _void): self.startup() diff --git a/pos_sound_settings.py b/pos_sound_settings.py new file mode 100644 index 0000000..e5719a8 --- /dev/null +++ b/pos_sound_settings.py @@ -0,0 +1,129 @@ +"""Shared POS sound choices, backed by an atomic file in the app's data directory.""" + +import json +import logging +import os +from pathlib import Path +import tempfile +import threading + +logger = logging.getLogger(__name__) + +DEFAULTS = { + "command": "KDE_Beep_ClassicBeep.wav", + "deposit": "itsgone.wav", + "insufficient_funds": "sinterklaas.wav", + "sale": "katsjing.wav", + "error": "KDE_Beep_Beep.wav", +} +LABELS = { + "command": "Command", + "deposit": "Deposit", + "insufficient_funds": "Insufficient funds", + "sale": "Sale", + "error": "Error", +} +SOUND_ROOT = Path(__file__).resolve().parent / "www" / "sounds" + + +class POSSoundSettings: + write_lock = threading.Lock() + + def __init__(self, path="data/revbank.sounds.json", root=SOUND_ROOT): + self.path = Path(path) + self.root = Path(root) + + def _choices(self, feature): + return sorted( + ( + path.name + for path in (self.root / "POS" / feature).iterdir() + if path.is_file() + and not path.is_symlink() + and path.suffix.lower() in (".wav", ".mp3", ".ogg") + ), + key=str.casefold, + ) + + def _read(self): + try: + saved = json.loads(self.path.read_text(encoding="utf-8")) + except FileNotFoundError: + return {} + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("Saved sound settings could not be read.") from error + if not isinstance(saved, dict): + raise ValueError("Saved sound settings could not be read.") + return saved + + def state(self): + saved = self._read() + features = [] + for feature, default in DEFAULTS.items(): + choices = self._choices(feature) + if default not in choices: + raise ValueError("A default POS sound is missing.") + selected = saved.get(feature) + features.append( + { + "id": feature, + "label": LABELS[feature], + "default": default, + "selected": selected if selected in choices else default, + "choices": choices, + } + ) + return {"features": features} + + def sample(self, feature): + default = DEFAULTS[feature] + try: + selected = self._read().get(feature, default) + if not isinstance(selected, str) or selected not in self._choices(feature): + selected = default + except (OSError, ValueError): + logger.warning( + "pos_sound_settings_unavailable; using defaults", exc_info=True + ) + selected = default + return "POS/" + feature + "/" + selected + + def _write(self, selected): + self.path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp( + prefix=self.path.name + ".", dir=self.path.parent, text=True + ) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as output: + json.dump(selected, output, sort_keys=True) + output.write("\n") + os.replace(temporary, self.path) + except Exception: + Path(temporary).unlink(missing_ok=True) + raise + + def set(self, feature, sound): + if ( + not isinstance(feature, str) + or feature not in DEFAULTS + or not isinstance(sound, str) + ): + raise ValueError("Unknown POS sound feature.") + with self.write_lock: + if sound not in self._choices(feature): + raise ValueError("Choose a sound from this feature's folder.") + selected = { + item["id"]: item["selected"] for item in self.state()["features"] + } + selected[feature] = sound + self._write(selected) + return self.state() + + def reset(self, feature=None): + if feature is not None: + if not isinstance(feature, str) or feature not in DEFAULTS: + raise ValueError("Unknown POS sound feature.") + return self.set(feature, DEFAULTS[feature]) + with self.write_lock: + self._write(dict(DEFAULTS)) + return self.state() diff --git a/purchase_history.py b/purchase_history.py new file mode 100644 index 0000000..a5c8788 --- /dev/null +++ b/purchase_history.py @@ -0,0 +1,101 @@ +"""Bounded, incremental read-only index of the existing checkout log.""" + +from collections import defaultdict, deque +from datetime import datetime, timedelta +from pathlib import Path +import re + +CHECKOUT = re.compile( + r"^(\S+) CHECKOUT\s+(\d+)\s+(\S+)\s+([\d.-]+) \*\s+([\d.-]+) " + r"(LOSE|GAIN) EUR\s+([\d.-]+) # (.*)$" +) +MAX_READ = 8 * 1024 * 1024 + + +class PurchaseIndex: + def __init__(self, path="data/revbank.log"): + self.path = Path(path) + self.identity = None + self.offset = 0 + self.signature = None + self.records = defaultdict(lambda: deque(maxlen=10)) + self.last_purchase = {} + self.coverage_start = None + self.complete = False + self.available = False + + def refresh(self): + try: + stat = self.path.stat() + signature = (stat.st_ino, stat.st_size, stat.st_mtime_ns) + if signature == self.signature: + self.available = True + return self + reset = (self.identity != stat.st_ino or stat.st_size < self.offset + or (stat.st_size == self.offset and signature != self.signature)) + skip_partial = False + if reset: + self.records.clear() + self.last_purchase.clear() + self.coverage_start = None + self.complete = stat.st_size <= MAX_READ + self.offset = max(0, stat.st_size - MAX_READ) + skip_partial = self.offset > 0 + if stat.st_size - self.offset > MAX_READ: + self.records.clear() + self.last_purchase.clear() + self.coverage_start = None + self.complete = False + self.offset = stat.st_size - MAX_READ + skip_partial = True + with self.path.open("rb") as source: + source.seek(self.offset) + if skip_partial: + source.readline() # Skip a partial first line at the window boundary. + data = source.read(MAX_READ) + end = data.rfind(b"\n") + 1 + self.offset = source.tell() - (len(data) - end) + for line in data[:end].decode("utf-8", errors="replace").splitlines(): + if len(line) >= 19 and line[4:5] == "-": + if self.coverage_start is None: + self.coverage_start = line[:19] + match = CHECKOUT.match(line) + if not match: + continue + date, transaction, user, count, price, direction, total, description = match.groups() + try: + count, price, total = float(count), float(price), float(total) + except ValueError: + continue + if (direction != "LOSE" or count <= 0 or price <= 0 + or description.startswith(("Undo ", "Give to ", "Taken by ", "Pfand ")) + or description == "Declaratie"): + continue + self.last_purchase[user] = max(self.last_purchase.get(user, date), date) + record = { + "date": date, "transaction": int(transaction), "count": count, + "price": price, "total": total, "description": description, + } + self.records[user] = deque(sorted([*self.records[user], record], key=lambda item: item["date"])[-10:], maxlen=10) + self.identity = stat.st_ino + self.signature = signature + self.available = True + except OSError: + self.identity = None + self.signature = None + self.available = False + return self + + def activity(self): + self.refresh() + cutoff = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d_%H:%M:%S") + covered = self.complete or (self.coverage_start is not None and self.coverage_start <= cutoff) + return {"available": self.available and covered, "cutoff": cutoff, + "last_purchase": dict(self.last_purchase)} + + def recent(self, user): + self.refresh() + return [dict(record) for record in reversed(self.records.get(user, []))] if self.available else [] + + +purchase_index = PurchaseIndex() diff --git a/tests/fixtures/button-shaders.html b/tests/fixtures/button-shaders.html new file mode 100644 index 0000000..9163c9f --- /dev/null +++ b/tests/fixtures/button-shaders.html @@ -0,0 +1,90 @@ + + + + + Ambient button shader checks + + + + + + + + + + + + + + + + + + diff --git a/tests/js/browser-smoke.test.js b/tests/js/browser-smoke.test.js index 81318da..e9c6b2b 100644 --- a/tests/js/browser-smoke.test.js +++ b/tests/js/browser-smoke.test.js @@ -19,7 +19,7 @@ function chromiumCommand() { }); } -function runChromium(command, url) { +function runChromium(command, url, extraArgs=[]) { const profile = fs.mkdtempSync(path.join(os.tmpdir(), "kassa-chromium-")); try { return spawnSync(command, [ @@ -27,12 +27,14 @@ function runChromium(command, url) { "--no-first-run", "--no-default-browser-check", "--headless", + "--window-size=1920,1080", "--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox", "--allow-file-access-from-files", "--dump-dom", "--virtual-time-budget=3000", + ...extraArgs, url, ], { encoding: "utf8", @@ -55,12 +57,49 @@ function scriptTag(scriptName) { return ``; } +for(const mode of ['animated','disabled','still']) { + const disabled=mode === 'disabled'; + test(disabled ? 'shader buttons keep their gradient and labels when WebGL is unavailable' : + (mode === 'still' ? 'ambient presets render still scenes with reduced motion' : 'ambient presets render distinct scenes through one shared WebGL context'), (t)=>{ + const command=chromiumCommand(); + if(!command) { t.skip('Chromium is not available'); return; } + const url='file://'+path.resolve(__dirname,'../fixtures/button-shaders.html')+'?report'+(disabled ? '&disabled' : ''); + const flags=['--enable-unsafe-swiftshader','--use-angle=swiftshader']; + if(mode === 'still') flags.push('--force-prefers-reduced-motion'); + const result=runChromium(command,url,flags); + if(skipIfChromiumSandboxed(t,result)) return; + assert.ifError(result.error); + assert.equal(result.status,0,result.stderr); + const match=result.stdout.match(/
([^<]+)<\/pre>/);
+    assert(match,result.stdout);
+    const data=JSON.parse(match[1].replaceAll('"','"').replaceAll('&','&'));
+    assert.equal(data.contexts,1);
+    assert.equal(data.sceneCount,10);
+    assert.equal(data.canvases,23);
+    assert.equal(data.labels,23);
+    assert.equal(data.fallback,true);
+    if(disabled) {
+      assert.equal(data.status,'unavailable');
+      assert(data.samples.every(sample=>sample.state==='fallback'));
+    } else {
+      assert.equal(data.status,'ready',result.stderr);
+      assert(data.samples.every(sample=>sample.state===mode && sample.bright>1000 && sample.width<=320 && sample.height<=128),JSON.stringify(data));
+      assert.equal(new Set(data.samples.map(sample=>sample.hash)).size,10);
+      assert.equal(new Set(data.seedSamples.slice(0,12).map(sample=>sample.hash)).size,12);
+      assert.equal(data.seedSamples.at(-1).hash,data.samples[0].hash);
+      assert.equal(data.preserved,true);
+    }
+  });
+}
+
 function writeKassaFixture(bodyScript) {
   const fixturePath = path.join(os.tmpdir(), `kassa-browser-smoke-${process.pid}-${Date.now()}.html`);
   fs.writeFileSync(fixturePath, `
 
   
     
+    
+    
     
@@ -77,6 +117,19 @@ function writeKassaFixture(bodyScript) {
     ${scriptTag("kassa-dom.js")}
     ${scriptTag("kassa-buttons.js")}
     ${scriptTag("kassa-stream.js")}
+    ${scriptTag("kassa-audio.js")}
+    ${scriptTag("kassa-sound-settings.js")}
+    ${scriptTag("shaders/wonderful.js")}
+    ${scriptTag("shaders/heartfelt.js")}
+    ${scriptTag("shaders/star-nest.js")}
+    ${scriptTag("shaders/clouds.js")}
+    ${scriptTag("shaders/universe-within.js")}
+    ${scriptTag("kassa-button-shaders.js")}
+    ${scriptTag("kassa-button-styles.js")}
+    ${scriptTag("kassa-button-studio.js")}
+    ${scriptTag("kassa-confetti.js")}
+    ${scriptTag("kassa-preferences.js")}
+    ${scriptTag("kassa-idle-focus.js")}
     ${scriptTag("kassa-app.js")}
     
   
@@ -139,6 +192,8 @@ test("kassa frontend posts keyboard and button input in a browser", (t) => {
           Object.defineProperty(keydown, "which", {value: 13});
           input.dispatchEvent(keydown);
           document.getElementById("bon").click();
+          input.value = "clubmate";
+          document.getElementById("CommandEnter").click();
 
           var output = document.createElement("pre");
           output.id = "smoke-result";
@@ -165,13 +220,368 @@ test("kassa frontend posts keyboard and button input in a browser", (t) => {
 
   assert.match(data.eventSourceUrl, /^stream\.php\?session=main&t=/);
   assert.equal(data.hasButtons, true);
+  assert.equal(data.activeElement, "Zoek");
   assert.deepEqual(data.fetchCalls.map((call) => call.body), [
     "topic=session%2Fmain%2Finput&msg=",
     "topic=session%2Fmain%2Finput&msg=abort",
     "topic=session%2Fmain%2Finput&msg=bon",
+    "topic=session%2Fmain%2Finput&msg=clubmate",
   ]);
 });
 
+test("filtering keeps account cell sizes and backspace restores the previous view", (t) => {
+  const command = chromiumCommand();
+  if(!command) {
+    t.skip("Chromium is not available");
+    return;
+  }
+  const fixturePath = writeKassaFixture(`
+    window.addEventListener("DOMContentLoaded", function() {
+      setTimeout(function() {
+        function send(topic, payload) {
+          window.__eventSource.onmessage({data: JSON.stringify([
+            'hack42bar/output/session/main/' + topic, JSON.stringify(payload)
+          ])});
+        }
+        var members = ['BugBlue', 'Jan', 'Johan', 'Jos', 'Josef', 'stitch'];
+        members.concat(['John']).forEach(function(name) { send('accounts/' + name, {amount: 12.5}); });
+        send('members', members);
+        send('nonmembers', ['John']);
+        send('buttons', {});
+        var before = document.querySelector('#AccountList #Jos').getBoundingClientRect();
+        var input = document.getElementById('Zoek');
+        var counts = [];
+        function filter(value) {
+          input.value = value;
+          input.dispatchEvent(new Event('input', {bubbles: true}));
+        }
+        filter('Jos');
+        setTimeout(function() {
+          counts.push(document.querySelectorAll('#AccountList .account').length);
+          var after = document.querySelector('#AccountList #Jos').getBoundingClientRect();
+          filter('Jo');
+          setTimeout(function() {
+            counts.push(document.querySelectorAll('#AccountList .account').length);
+            filter('J');
+            setTimeout(function() {
+              counts.push(document.querySelectorAll('#AccountList .account').length);
+              filter('');
+              setTimeout(function() {
+                counts.push(document.querySelectorAll('#AccountList .account').length);
+                var output = document.createElement('pre');
+                output.id = 'smoke-result';
+                output.textContent = JSON.stringify({
+                  beforeWidth: before.width, afterWidth: after.width,
+                  beforeHeight: before.height, afterHeight: after.height,
+                  counts: counts, fetchCount: window.__fetchCalls.length,
+                  hasCash: !!document.querySelector('#TopButtons #cash')
+                });
+                document.body.appendChild(output);
+              }, 250);
+            }, 250);
+          }, 250);
+        }, 250);
+      }, 0);
+    });
+  `);
+  const result = runChromium(command, `file://${fixturePath}`);
+  fs.unlinkSync(fixturePath);
+  if(skipIfChromiumSandboxed(t, result)) return;
+  assert.ifError(result.error);
+  assert.equal(result.status, 0, result.stderr);
+  const match = result.stdout.match(/
([^<]+)<\/pre>/);
+  assert(match, result.stdout);
+  const data = JSON.parse(match[1].replaceAll(""", '"').replaceAll("&", "&"));
+  assert(data.beforeWidth > 0);
+  assert.equal(data.beforeHeight, 84);
+  assert(Math.abs(data.beforeWidth - data.afterWidth) < 0.5, JSON.stringify(data));
+  assert(Math.abs(data.beforeHeight - data.afterHeight) < 0.5, JSON.stringify(data));
+  assert.deepEqual(data.counts, [2, 4, 5, 6]);
+  assert.equal(data.fetchCount, 1);
+  assert.equal(data.hasCash, true);
+});
+
+test('sunglasses quiet both account lists without sending a command or changing saved styles', (t)=>{
+  const command=chromiumCommand();
+  if(!command){t.skip('Chromium is not available');return;}
+  const fixture=writeKassaFixture(`
+    window.addEventListener('DOMContentLoaded',function(){setTimeout(function(){
+      var result={};
+      try{
+        function send(topic,value){window.__eventSource.onmessage({data:JSON.stringify(['hack42bar/output/session/main/'+topic,JSON.stringify(value)])});}
+        send('accounts/BugBlue',{amount:-20});send('accounts/stitch',{amount:42});send('accounts/guest',{amount:1});
+        send('members',['BugBlue','stitch']);send('nonmembers',['guest']);send('buttons',{});
+        var account=document.querySelector('#AccountList #BugBlue'), before=account.getBoundingClientRect();
+        var original=account.innerHTML, input=document.getElementById('Zoek');input.value='unfinished command';
+        var posts=window.__fetchCalls.length;
+        var toggle=document.querySelector('.button-styles-toggle');
+        result.archiveThenToggle=toggle.previousElementSibling.classList.contains('archive');
+        toggle.click();
+        result.off=toggle.getAttribute('aria-pressed')==='false' && document.body.classList.contains('button-styles-off');
+        result.plain=getComputedStyle(account.querySelector('.magic-layer')).display==='none' && getComputedStyle(account.querySelector('.Buttontext')).fontSize==='17px';
+        result.draftKept=input.value==='unfinished command';
+        result.focus=input===document.activeElement;
+        var after=account.getBoundingClientRect();
+        result.sameSize=before.width===after.width && before.height===after.height;
+        result.balance=account.querySelector('.extra').textContent==='-20.00' && account.querySelector('.extra').classList.contains('rood');
+        document.body.classList.add('dark-mode');
+        result.darkInk=getComputedStyle(account.querySelector('.Buttontext')).color==='rgb(225, 234, 243)';
+        var preview=HackBankButtonStyles.preview('preview',42,HackBankButtonStyles.preset(0));
+        document.body.appendChild(preview);
+        result.studioShowsDesign=getComputedStyle(preview.querySelector('.magic-layer')).display!=='none';preview.remove();
+        document.getElementById('otherusers').click();
+        toggle=document.querySelector('.button-styles-toggle');
+        result.guestsOff=toggle.getAttribute('aria-pressed')==='false' && toggle.previousElementSibling.classList.contains('archive');
+        document.getElementById('members').click();
+        document.querySelector('.button-styles-toggle').click();
+        result.restored=!document.body.classList.contains('button-styles-off') && document.querySelector('#AccountList #BugBlue').innerHTML===original;
+        result.noCommand=window.__fetchCalls.length===posts;
+      }catch(error){result.error=String(error.stack);}
+      var output=document.createElement('pre');output.id='smoke-result';output.textContent=JSON.stringify(result);document.body.appendChild(output);
+    },0);});
+  `);
+  const result=runChromium(command,'file://'+fixture);
+  fs.unlinkSync(fixture);
+  if(skipIfChromiumSandboxed(t,result))return;
+  assert.ifError(result.error);assert.equal(result.status,0,result.stderr);
+  const match=result.stdout.match(/
([^<]+)<\/pre>/);assert(match,result.stdout);
+  const data=JSON.parse(match[1].replaceAll('"','"').replaceAll('&','&'));
+  assert.equal(data.error,undefined,JSON.stringify(data));
+  Object.keys(data).forEach(key=>assert.equal(data[key],true,key+': '+JSON.stringify(data)));
+});
+
+test("button studio previews locally, saves one user's design and cancels without a POS command", (t) => {
+  const command=chromiumCommand();
+  if(!command) { t.skip("Chromium is not available"); return; }
+  const fixturePath=writeKassaFixture(`
+    window.addEventListener('DOMContentLoaded',function() {
+      setTimeout(async function() {
+        var result={};
+        function send(topic,payload) {
+          window.__eventSource.onmessage({data:JSON.stringify(['hack42bar/output/session/main/'+topic,JSON.stringify(payload)])});
+        }
+        function wait(ms) { return new Promise(function(resolve) { setTimeout(resolve,ms); }); }
+        var saved=null, requests=[];
+        window.fetch=function(url,options) {
+          var params=new URLSearchParams(options.body);
+          var topic=params.get('topic'); requests.push(topic);
+          if(topic.endsWith('/button_studio')) {
+            var change=JSON.parse(params.get('msg'));
+            if(change.theme) saved=change.theme;
+            setTimeout(function() { send('button_studio',{request:change.request,ok:true,user:change.user,theme:saved}); },10);
+          }
+          return Promise.resolve({ok:true,json:function() { return Promise.resolve({done:1}); }});
+        };
+        try {
+          send('accounts/stitch',{amount:10}); send('accounts/BugBlue',{amount:-20});
+          send('members',['stitch','BugBlue']); send('buttons',{});
+          var other=document.querySelector('#BugBlue').innerHTML;
+          send('infobox/account',{user:'stitch',amount:10,purchases:[],history_available:true,undo_transaction:null});
+          send('buttons',{special:'infobox'});
+          document.querySelector('.studio-launch').click(); await wait(30);
+          var dialog=document.querySelector('.button-studio');
+          var current=dialog.querySelector('.studio-current').innerHTML;
+          var presets=Array.from(dialog.querySelectorAll('.studio-presets .studio-preset'));
+          result.allPresetsTogether=presets.length===16 && !dialog.querySelector('.studio-ambient');
+          presets[6].click();
+          var previewCanvas=dialog.querySelector('.studio-new .magic-shader-canvas');
+          presets[7].click(); presets[8].click(); presets[9].click();
+          result.previewCanvasPreserved=previewCanvas===dialog.querySelector('.studio-new .magic-shader-canvas');
+          result.presetCanvasesPreserved=presets.every(function(tile,index) { return tile===dialog.querySelectorAll('.studio-presets .studio-preset')[index]; });
+          dialog.querySelectorAll('.studio-preset')[0].click();
+          var style=dialog.querySelector('select'); style.value='plasma'; style.dispatchEvent(new Event('change'));
+          document.querySelector('#Studio-tab-Lettering').click();
+          var size=Array.from(dialog.querySelectorAll('input')).find(function(el) { return el.getAttribute('aria-label')==='Font size value'; });
+          size.value='30'; size.dispatchEvent(new Event('input'));
+          var backdrop=Array.from(dialog.querySelectorAll('select')).find(function(el) { return el.labels[0].textContent==='Label backdrop'; });
+          backdrop.value='on'; backdrop.dispatchEvent(new Event('change'));
+          result.currentUnchanged=current===dialog.querySelector('.studio-current').innerHTML;
+          result.livePlasma=!!dialog.querySelector('.studio-new .magic-plasma');
+          result.liveBackdrop=!!dialog.querySelector('.studio-new .has-label-backdrop');
+          result.previewSize=getComputedStyle(dialog.querySelector('.studio-new .Buttontext')).fontSize;
+          var postsBefore=requests.length;
+          dialog.querySelector('.studio-save').click(); await wait(900);
+          result.savedExactlyOnce=requests.length===postsBefore+1;
+          document.querySelector('#members').click();
+          result.savedFontSize=getComputedStyle(document.querySelector('#stitch .Buttontext')).fontSize;
+          result.savedBackdrop=!!document.querySelector('#stitch .has-label-backdrop');
+          result.otherUnchanged=other===document.querySelector('#BugBlue').innerHTML;
+          result.bounded=getComputedStyle(document.querySelector('#stitch')).overflow==='hidden';
+          send('infobox/account',{user:'stitch',amount:10,purchases:[],history_available:true,undo_transaction:null});
+          send('buttons',{special:'infobox'}); document.querySelector('.studio-launch').click(); await wait(30);
+          dialog=document.querySelector('.button-studio');
+          dialog.querySelectorAll('.studio-preset')[2].click();
+          postsBefore=requests.length; dialog.querySelector('.studio-cancel').click();
+          result.cancelSilent=requests.length===postsBefore;
+          result.noFinancialCommands=requests.every(function(topic) { return topic.endsWith('/button_studio'); });
+        } catch(error) { result.error=String(error.stack); }
+        var output=document.createElement('pre'); output.id='smoke-result'; output.textContent=JSON.stringify(result); document.body.appendChild(output);
+      },0);
+    });
+  `);
+  const result=runChromium(command,`file://${fixturePath}`);
+  fs.unlinkSync(fixturePath);
+  if(skipIfChromiumSandboxed(t,result)) return;
+  assert.ifError(result.error);
+  assert.equal(result.status,0,result.stderr);
+  const match=result.stdout.match(/
([^<]+)<\/pre>/);
+  assert(match,result.stdout);
+  const data=JSON.parse(match[1].replaceAll('"','"').replaceAll('&','&'));
+  assert.equal(data.error,undefined,JSON.stringify(data));
+  assert.equal(data.currentUnchanged,true);
+  assert.equal(data.allPresetsTogether,true);
+  assert.equal(data.previewCanvasPreserved,true);
+  assert.equal(data.presetCanvasesPreserved,true);
+  assert.equal(data.livePlasma,true);
+  assert.equal(data.liveBackdrop,true);
+  assert.equal(data.previewSize,'30px');
+  assert.equal(data.savedFontSize,'30px');
+  assert.equal(data.savedBackdrop,true);
+  assert.equal(data.savedExactlyOnce,true);
+  assert.equal(data.otherUnchanged,true);
+  assert.equal(data.bounded,true);
+  assert.equal(data.cancelSilent,true);
+  assert.equal(data.noFinancialCommands,true);
+});
+
+test('Matrix, Cyber and name cases preview, save and preserve original account commands', (t)=>{
+  const command=chromiumCommand();
+  if(!command) { t.skip('Chromium is not available'); return; }
+  const fixture=writeKassaFixture(`
+    window.addEventListener('DOMContentLoaded',function() { setTimeout(async function() {
+      var result={};
+      try {
+        function send(topic,value) { window.__eventSource.onmessage({data:JSON.stringify(['hack42bar/output/session/main/'+topic,JSON.stringify(value)])}); }
+        function wait(ms) { return new Promise(function(resolve) { setTimeout(resolve,ms); }); }
+        var saved=null, writes=[];
+        window.fetch=function(url,options) {
+          var params=new URLSearchParams(options.body);
+          writes.push({topic:params.get('topic'),message:params.get('msg')});
+          if(params.get('topic').endsWith('/button_studio')) {
+            var request=JSON.parse(params.get('msg'));
+            if(request.theme) saved=request.theme;
+            setTimeout(function() { send('button_studio',{request:request.request,ok:true,user:request.user,theme:saved}); },10);
+          }
+          return Promise.resolve({ok:true,json:function() { return Promise.resolve({done:1}); }});
+        };
+        send('accounts/BugBlue',{amount:-20}); send('accounts/stitch',{amount:10});
+        send('members',['BugBlue','stitch']); send('buttons',{});
+        var other=document.querySelector('#stitch').innerHTML;
+        function openStudio() {
+          send('infobox/account',{user:'BugBlue',amount:-20,purchases:[],history_available:true,undo_transaction:null});
+          send('buttons',{special:'infobox'}); document.querySelector('.studio-launch').click();
+        }
+        openStudio(); await wait(30);
+        var dialog=document.querySelector('.button-studio');
+        var current=dialog.querySelector('.studio-current').innerHTML;
+        function choose(title,value) {
+          var select=Array.from(dialog.querySelectorAll('select')).find(function(input) { return input.labels[0].textContent===title; });
+          select.value=value; select.dispatchEvent(new Event('change'));
+        }
+        function lettering() { return dialog.querySelector('.studio-new .Buttontext'); }
+        function matrix() { Array.from(dialog.querySelectorAll('.studio-preset')).find(function(tile) { return tile.textContent==='Matrix'; }).click(); }
+        matrix(); document.querySelector('#Studio-tab-Lettering').click();
+        choose('Font','cyber');
+        result.caseOptions=Array.from(dialog.querySelectorAll('select')).find(function(input) { return input.labels[0].textContent==='Name case'; }).options.length===8;
+        choose('Name case','uppercase'); result.uppercase=lettering().textContent==='BUGBLUE';
+        choose('Name case','snake'); result.wordDetection=lettering().textContent==='bug_blue';
+        choose('Name case','original'); result.original=lettering().textContent==='BugBlue';
+        choose('Name case','camel');
+        document.querySelector('#Studio-tab-Background').click(); matrix();
+        result.presetPreservesLettering=lettering().textContent==='bugBlue' && getComputedStyle(lettering()).fontFamily.includes('HackBank Cyber');
+        result.matrixPreview=!!dialog.querySelector('.studio-new .magic-shader-matrix');
+        result.currentUnchanged=current===dialog.querySelector('.studio-current').innerHTML;
+        var faces=await document.fonts.load('17px "HackBank Cyber"','BugBlue');
+        result.cyberLoaded=faces.length===1 && faces[0].status==='loaded';
+        dialog.querySelector('.studio-save').click(); await wait(900);
+        result.saved=saved.background.kind==='shader-matrix' && saved.text.font==='cyber' && saved.text.case==='camel';
+        document.querySelector('#members').click();
+        var account=document.querySelector('#BugBlue'), canvas=account.querySelector('canvas');
+        result.account=account.querySelector('.Buttontext').textContent==='bugBlue' && account.getAttribute('data-button-user')==='BugBlue';
+        HackBankButtonStyles.apply(account,'BugBlue',saved); HackBankButtonStyles.apply(account,'BugBlue',saved);
+        result.repeatedApply=account.querySelector('.Buttontext').textContent==='bugBlue' && account.querySelector('canvas')===canvas;
+        var toggle=document.querySelector('.button-styles-toggle'); toggle.click();
+        result.quietName=account.querySelector('.Buttontext').textContent==='BugBlue';
+        var preview=HackBankButtonStyles.preview('BugBlue',-20,saved); document.body.appendChild(preview);
+        result.quietPreview=preview.querySelector('.Buttontext').textContent==='bugBlue'; preview.remove();
+        toggle.click(); result.restoredName=account.querySelector('.Buttontext').textContent==='bugBlue';
+        result.otherUnchanged=document.querySelector('#stitch').innerHTML===other;
+        openStudio(); await wait(30); dialog=document.querySelector('.button-studio');
+        document.querySelector('#Studio-tab-Lettering').click();
+        result.reopened=lettering().textContent==='bugBlue';
+        choose('Name case','uppercase');
+        var posts=writes.length; dialog.querySelector('.studio-cancel').click();
+        document.querySelector('#members').click();
+        result.cancelled=writes.length===posts && document.querySelector('#BugBlue .Buttontext').textContent==='bugBlue';
+        var input=document.querySelector('#Zoek'); input.value='bugb'; input.dispatchEvent(new Event('input')); await wait(250);
+        result.filtered=!!document.querySelector('#AccountList #BugBlue');
+        input.dispatchEvent(new KeyboardEvent('keydown',{key:'Tab',keyCode:9,which:9,bubbles:true}));
+        result.completion=input.value==='BugBlue ';
+        result.editingSendsNoCommands=writes.every(function(write) { return write.topic.endsWith('/button_studio'); });
+        input.value=''; document.querySelector('#AccountList #BugBlue').click();
+        result.command=writes.at(-1).topic.endsWith('/input') && writes.at(-1).message==='BugBlue';
+      } catch(error) { result.error=String(error.stack); }
+      var output=document.createElement('pre'); output.id='smoke-result'; output.textContent=JSON.stringify(result); document.body.appendChild(output);
+    },0); });
+  `);
+  const result=runChromium(command,'file://'+fixture);
+  fs.unlinkSync(fixture);
+  if(skipIfChromiumSandboxed(t,result)) return;
+  assert.ifError(result.error); assert.equal(result.status,0,result.stderr);
+  const match=result.stdout.match(/
([^<]+)<\/pre>/); assert(match,result.stdout);
+  const data=JSON.parse(match[1].replaceAll('"','"').replaceAll('&','&'));
+  assert.equal(data.error,undefined,JSON.stringify(data));
+  Object.keys(data).forEach(key=>assert.equal(data[key],true,key+': '+JSON.stringify(data)));
+});
+
+test("a completed deposit displays a native payment dialog and dismisses through OK", (t) => {
+  const command=chromiumCommand();
+  if(!command) { t.skip('Chromium is not available'); return; }
+  const repo=path.resolve(__dirname,'../..');
+  const payment=spawnSync(path.join(repo,'.venv/bin/python'),[
+    '-c','import json; from epc_payment import create_payment; print(json.dumps(create_payment("test_user", "25.50")))',
+  ],{cwd:repo,encoding:'utf8'});
+  assert.equal(payment.status,0,payment.stderr);
+  const fixture=writeKassaFixture(`
+    window.addEventListener('DOMContentLoaded',function() {
+      setTimeout(function() {
+        var result={};
+        try {
+          window.__eventSource.onmessage({data:JSON.stringify([
+            'hack42bar/output/session/main/deposit_payment',JSON.stringify({payments:[${payment.stdout.trim()}]})
+          ])});
+          var dialog=document.getElementById('DepositPayment');
+          var image=dialog.querySelector('img');
+          result.modal=dialog.open && dialog.matches(':modal');
+          result.qrWidth=image.getBoundingClientRect().width;
+          result.details=dialog.textContent;
+          dialog.querySelector('button').click();
+          result.closed=!document.getElementById('DepositPayment');
+          result.lastPost=window.__fetchCalls.at(-1).body;
+        } catch(error) { result.error=String(error.stack); }
+        var output=document.createElement('pre'); output.id='smoke-result';
+        output.textContent=JSON.stringify(result); document.body.appendChild(output);
+      },100);
+    });
+  `);
+  const result=runChromium(command,`file://${fixture}`);
+  fs.unlinkSync(fixture);
+  if(skipIfChromiumSandboxed(t,result)) return;
+  assert.ifError(result.error);
+  assert.equal(result.status,0,result.stderr);
+  const match=result.stdout.match(/
([^<]+)<\/pre>/);
+  assert(match,result.stdout);
+  const data=JSON.parse(match[1].replaceAll('"','"').replaceAll('&','&'));
+  assert.equal(data.error,undefined,JSON.stringify(data));
+  assert.equal(data.modal,true);
+  assert(data.qrWidth>=300);
+  assert.match(data.details,/NL43ABNA0136777643/);
+  assert.match(data.details,/25.50/);
+  assert.match(data.details,/test_user/);
+  assert.equal(data.closed,true);
+  assert.equal(data.lastPost,'topic=session%2Fmain%2Finput&msg=ok');
+});
+
 test("the checked-in jQuery vendor file is gone", () => {
   assert.equal(fs.existsSync(path.resolve(__dirname, "../../www/jquery.min.js")), false);
 });
diff --git a/tests/js/index-scripts.test.js b/tests/js/index-scripts.test.js
index b112ba3..57669e4 100644
--- a/tests/js/index-scripts.test.js
+++ b/tests/js/index-scripts.test.js
@@ -7,7 +7,7 @@ test("main index loads split kassa scripts in dependency order", () => {
   const indexPath = path.join(__dirname, "../../www/index.html");
   const html = fs.readFileSync(indexPath, "utf8");
   const scripts = Array.from(html.matchAll(/
-    
-    
-    
-    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
+    
   
-  
+  
+    
   
 
diff --git a/www/kassa-app.js b/www/kassa-app.js
index fd4829e..c7f9a3e 100644
--- a/www/kassa-app.js
+++ b/www/kassa-app.js
@@ -15,6 +15,33 @@ document.addEventListener("DOMContentLoaded", function() {
   var tabenable=0;
   var question="";
   var locked=0;
+  var pagedButtons=null;
+  var filterRestore=null;
+  var filtering=false;
+  var audioControl=null;
+  var soundSettingsControl=null;
+  var soundSettings=null;
+  var buttonThemes=Object.create(null);
+  var buttonStudio=window.HackBankButtonStudio ? window.HackBankButtonStudio.create({
+    session:session,
+    getTheme:function(user) { return buttonThemes[user] || null; },
+    onSaved:updateButtonTheme,
+  }) : null;
+  var currentView='accounts';
+  var commandCategory='All commands';
+  var soundButtons=[];
+  var activity=null;
+  var accountProfile=null;
+  var accountType='m';
+  var archivedAccounts=false;
+  var accountSections=null;
+  var filterReturnView=null;
+  var preferences=window.HackBankPreferences ? window.HackBankPreferences.create({
+    sendCommand: function(command) {
+      postmsg('input',command);
+    },
+    playSound: playsound,
+  }) : null;
   var buttons=window.HackBankButtons;
   var dom=window.HackBankDom;
   var allElements=dom.allElements;
@@ -32,6 +59,16 @@ document.addEventListener("DOMContentLoaded", function() {
   var topButton=dom.topButton;
 
   function postmsg(topic,msg) {
+    if(topic === 'input') removeElement('#DepositPayment');
+    if(topic === 'input' && preferences && preferences.handleCommand(msg)) {
+      preferences.command();
+      if(currentView === 'commands') locked=1;
+      if(filterReturnView === 'commands') { filterReturnView=null; makeCommandScreen(commandCategory); }
+      else if(filterRestore) { restoreSelection(filterRestore); filterRestore=null; }
+      return;
+    }
+    if(topic === 'input') filterReturnView=null;
+    if(topic === 'input' && msg.trim() && preferences) preferences.command();
     fetch("post.php", {
       method: "POST",
       headers: {
@@ -120,6 +157,7 @@ document.addEventListener("DOMContentLoaded", function() {
     if(label) label.innerHTML=label.innerHTML + html;
   }
   function removeActiveTopButtons() {
+    stopAudio();
     allElements('.topknop').forEach(function(button) {
       button.classList.remove('activetop');
     });
@@ -130,20 +168,56 @@ document.addEventListener("DOMContentLoaded", function() {
     fillVisibleText(".Paginatext:visible");
   }
   function activateTopButton(id) {
+    stopAudio();
     allElements('.topknop').forEach(function(button) {
       button.classList.remove('activetop');
     });
     var button=document.getElementById(id);
     if(button) button.classList.add('activetop');
   }
+  function stopAudio() {
+    if(audioControl) audioControl.stop();
+    audioControl=null;
+    if(soundSettingsControl) soundSettingsControl.stop();
+    soundSettingsControl=null;
+  }
   function showAccountButtons(activeButtonId, accountType, tabMode) {
     activateTopButton(activeButtonId);
     showElement('#Secondscreen');
-    makepages('normal',buttons.accountsToButtons(accounts,members,nonmembers,accountType));
+    currentView='accounts';
+    makeAccountSections(accountType,false);
     prependCashButton();
     focus();
     tabenable=tabMode;
   }
+  function makeAccountSections(type, archive) {
+    accountType=type;
+    archivedAccounts=archive;
+    var candidates=buttons.accountsToButtons(accounts,members,nonmembers,type);
+    if(type === 'o') candidates=candidates.concat(buttons.accountsToButtons(accounts,members,nonmembers,'x')).sort(function(a,b) { return a.display.toLowerCase().localeCompare(b.display.toLowerCase()); });
+    accountSections=buttons.splitAccounts(candidates,accounts,activity);
+    makeAccountList(archive ? accountSections.archive : accountSections.active);
+    addArchiveTabs();
+  }
+  function addArchiveTabs() {
+    if(!accountSections) return;
+    var top=document.getElementById('TopButtons');
+    var name=accountType === 'm' ? 'Members' : 'Guests';
+    var tools=createElement('div',{class:'account-display-tools'});
+    [[false,name],[true,name+' Archive']].forEach(function(item) {
+      var tab=createElement('button',{type:'button',class:'archive-tab'+(item[0] ? ' archive' : '')+(archivedAccounts === item[0] ? ' selected' : ''),text:item[1]+' ('+accountSections[item[0] ? 'archive' : 'active'].length+')', 'aria-pressed':String(archivedAccounts === item[0])});
+      if(item[0] && (!activity || !activity.available)) { tab.disabled=true; tab.title='Archive unavailable until purchase history can be checked'; }
+      tab.addEventListener('click',function() { makeAccountSections(accountType,item[0]); prependCashButton(); focus(); });
+      if(item[0]) tools.appendChild(tab);
+      else top.prepend(tab);
+    });
+    if(window.HackBankButtonStyles) {
+      var appearance=window.HackBankButtonStyles.toggleButton();
+      appearance.addEventListener('click',focus);
+      tools.appendChild(appearance);
+    }
+    top.appendChild(tools);
+  }
   function build_receipt(msg) {
     var parts=JSON.parse(msg);
     var receipt=first('#Receipt');
@@ -162,13 +236,17 @@ document.addEventListener("DOMContentLoaded", function() {
     });
     scrollToBottom('#Receipt');
     if(counter === 0) {
-      appendToElement(receipt, createElement('div',{class: 'welcome', text: "Welcome to Hack42 Bank HTML5 Interface"}));
+      appendToElement(receipt, createElement('div',{class: 'welcome', text: "Your next round starts here. Scan a product or choose a button."}));
     }
     clearElement('#Receipt2');
     cloneElementsTo('.Itemline', '#Receipt2');
     scrollToBottom('#Receipt2');
     if (receipt2 && receipt2.childElementCount === 0){
-      appendToElement(receipt2, createElement('img',{src: 'images/Hack42.png', width: '100%', style: {top: '12vh', position: 'absolute'}}));
+      appendToElement(receipt2, appendChildren(createElement('div',{class: 'receipt-empty'}), [
+        createElement('span',{class: 'empty-symbol', text: '+', 'aria-hidden': 'true'}),
+        createElement('strong',{text: 'Nothing on the tab yet'}),
+        createElement('span',{text: 'Scan a product to get started.'}),
+      ]));
     }
   }
   function build_totals(msg) {
@@ -192,6 +270,10 @@ document.addEventListener("DOMContentLoaded", function() {
   }
   function run_infobox(name,msg) {
     var json=JSON.parse(msg);
+    if(json.user) {
+      accountProfile=json;
+      return;
+    }
     var newsaldo=json.amount;
     var infobox=first('.infoboxes .Total_'+name);
     appendToElement(infobox, createElement('span',{class: 'NowHas',text: "Now Has"}));
@@ -204,8 +286,16 @@ document.addEventListener("DOMContentLoaded", function() {
     }
     accounts[name]=JSON.parse(msg);
   }
+  function updateButtonTheme(user,theme) {
+    buttonThemes[user]=theme;
+    if(!window.HackBankButtonStyles) return;
+    allElements('#MainButtons .account').forEach(function(button) {
+      if(button.getAttribute('data-button-user') === user) window.HackBankButtonStyles.apply(button,user,theme);
+    });
+  }
   function setupcommands(msg) {
     commands=JSON.parse(msg);
+    if(preferences) Object.assign(commands,preferences.commands);
   }
   function setupstock(name,msg) {
     stock[name]=msg;
@@ -215,18 +305,114 @@ document.addEventListener("DOMContentLoaded", function() {
     buttons.rebuildProductIndexes(prods, groups, nobcgroup);
   }
 
+  function showDepositPayment(state) {
+    removeElement('#DepositPayment');
+    if(!state.payments || !state.payments.length) return;
+    var dialog=createElement('dialog',{id:'DepositPayment',class:'deposit-payment','aria-labelledby':'DepositPaymentTitle'});
+    dialog.appendChild(createElement('h2',{id:'DepositPaymentTitle',text:'Bankoverschrijving'}));
+    state.payments.forEach(function(payment) {
+      var entry=createElement('section',{class:'deposit-payment-entry'});
+      entry.appendChild(createElement('img',{
+        class:'deposit-payment-qr',src:payment.qr,
+        alt:'EPC-betaalcode voor '+payment.reference+' - EUR '+payment.amount,
+        width:400,height:400,
+      }));
+      var details=createElement('dl',{class:'deposit-payment-details'});
+      [['Bedrag','EUR '+payment.amount],['Ontvanger',payment.name],['IBAN',payment.iban],['Omschrijving',payment.reference]].forEach(function(field) {
+        details.appendChild(createElement('dt',{text:field[0]}));
+        details.appendChild(createElement('dd',{text:field[1]}));
+      });
+      entry.appendChild(details);
+      dialog.appendChild(entry);
+    });
+    var close=createElement('button',{type:'button',class:'deposit-payment-close',text:'Sluiten'});
+    function dismiss() {
+      postmsg('input','ok');
+      focus();
+    }
+    close.addEventListener('click',dismiss);
+    dialog.addEventListener('cancel',function(event) {
+      event.preventDefault();
+      dismiss();
+    });
+    dialog.appendChild(close);
+    document.body.appendChild(dialog);
+    dialog.showModal();
+    close.focus();
+  }
+
   function makepage_infobox() {
     clearElement('#MainButtons');
     clearElement('#TopButtons');
-    appendTo('#MainButtons', createElement('div',{class: "infoboxes", id: "infoboxes"}));
-    cloneElementsTo('.Userline', '#infoboxes');
+    if(!accountProfile || accountProfile.checkout) {
+      appendTo('#MainButtons', createElement('div',{class: "infoboxes", id: "infoboxes"}));
+      cloneElementsTo('#Totals .Userline', '#infoboxes');
+    }
+    if(accountProfile) {
+      makeUserProfile(accountProfile);
+    }
     appendTo('#TopButtons', buttonElement("Knopje Button normal ok",'ok',"OK"));
-    appendTo('#TopButtons', buttonElement("Knopje Button normal undo",'undo',"Undo"));
+    appendTo('#TopButtons', buttonElement("Knopje Button normal undo",'undo',"Undo last transaction"));
     appendTo('#TopButtons', buttonElement("Knopje Button normal restore",'restore',"Undo + Restore"));
-    appendTo('#TopButtons', buttonElement("Knopje Button normal bon",'bon',"Bon"));
-    appendTo('#TopButtons', buttonElement("Knopje Button normal kassala",'kassala',"Kassala"));
+    appendTo('#TopButtons', buttonElement("Knopje Button normal bon",'bon',"Print receipt"));
+    appendTo('#TopButtons', buttonElement("Knopje Button normal kassala",'kassala',"Open cash drawer"));
     fillVisibleText("#TopButtons .Knopjetext:visible");
   }
+  function actionButton(label, command, className) {
+    var button=createElement('button',{type:'button',class:className || 'profile-action',text:label});
+    button.addEventListener('click',function() {
+      locked=0;
+      currentView='action';
+      postmsg('input',command);
+      setSearchValue(''); focus();
+    });
+    return button;
+  }
+  function makeUserProfile(profile) {
+    currentView='profile';
+    var screen=createElement('section',{class:'user-profile'});
+    var heading=createElement('div',{class:'profile-heading'});
+    heading.appendChild(createElement('h1',{text:profile.user}));
+    var balanceClass=profile.amount < -13.37 ? ' rood' : (profile.amount < 0 ? ' orange' : '');
+    heading.appendChild(createElement('span',{class:'profile-balance'+balanceClass,text:'Balance €'+profile.amount.toFixed(2)}));
+    screen.appendChild(heading);
+    var actions=createElement('div',{class:'profile-actions'});
+    actions.appendChild(actionButton('Full user history','history '+profile.user));
+    if(buttonStudio) {
+      var studioButton=createElement('button',{type:'button',class:'profile-action studio-launch',text:'✨ Button studio'});
+      studioButton.addEventListener('click',function() { buttonStudio.open(profile); });
+      actions.appendChild(studioButton);
+    }
+    if(profile.undo_transaction !== null && profile.undo_transaction !== undefined) {
+      actions.appendChild(actionButton('Undo latest transaction','undouserlatest '+profile.undo_transaction+':'+profile.user,'profile-action undo-user-transaction'));
+    }
+    if(profile.user !== 'cash') {
+      var isMember=typeof profile.is_member === 'boolean' ? profile.is_member : members.includes(profile.user);
+      actions.appendChild(actionButton(isMember ? 'Promote to guest' : 'Promote to Member',
+        (isMember ? 'delmember ' : 'addmember ')+profile.user,'profile-action membership-action'));
+    }
+    screen.appendChild(actions);
+    screen.appendChild(createElement('h2',{text:'Last 10 purchases'}));
+    screen.appendChild(createElement('p',{class:'profile-hint',text:'Add again uses the current price and adds to the current sale.'}));
+    if(profile.history_available && profile.history_complete === false) screen.appendChild(createElement('p',{class:'profile-hint',text:'Recent history is limited; open full user history for older purchases.'}));
+    var rows=createElement('div',{class:'purchase-list'});
+    (profile.purchases || []).forEach(function(purchase) {
+      var row=createElement('div',{class:'purchase-row'});
+      var details=createElement('div');
+      details.appendChild(createElement('strong',{text:purchase.count+' × '+purchase.description}));
+      details.appendChild(createElement('span',{class:'purchase-date',text:purchase.date.replace('_',' ')+' · paid €'+purchase.total.toFixed(2)}));
+      row.appendChild(details);
+      if(purchase.product && prods[purchase.product] && purchase.count > 0 && purchase.count < 100) {
+        var command=purchase.count === 1 ? purchase.product : purchase.count+'* '+purchase.product;
+        row.appendChild(actionButton('Add again · €'+(prods[purchase.product].price*purchase.count).toFixed(2),command,'profile-action reorder-purchase'));
+      }
+      rows.appendChild(row);
+    });
+    if(!rows.childElementCount) rows.appendChild(createElement('p',{class:'profile-hint',text:profile.history_available ? 'No purchases found.' : 'Purchase history is unavailable.'}));
+    screen.appendChild(rows);
+    appendTo('#MainButtons',screen);
+    appendTo('#TopButtons',actionButton('Back to members','abort','profile-action'));
+  }
   function dokeys(keys) {
     var keysElement=document.getElementById('keys');
     keys.forEach(function(val) {
@@ -285,7 +471,7 @@ document.addEventListener("DOMContentLoaded", function() {
       appendToElement(mylines, document.createTextNode(val));
       appendBreak(mylines);
     });
-    setElementStyle('#mylines', {position: 'relative', top: '-12vh', left: '0px', zIndex: '100', background: 'lightgray', height: '76vh', overflowWrap: 'break-word', overflowY: 'scroll', width: '96vw'});
+    setElementStyle('#mylines', {overflowWrap: 'break-word', overflowY: 'auto'});
     scrollToBottom('#mylines');
     showquestion();
   }
@@ -303,49 +489,152 @@ document.addEventListener("DOMContentLoaded", function() {
     });
     showquestion();
   }
-  function makepages(extraclass,buttons) {
+  function buttonsPerPage(accountList) {
+    var mainButtons=first('#MainButtons');
+    if(!mainButtons || !window.getComputedStyle) return 25;
+    var style=window.getComputedStyle(mainButtons);
+    if(accountList) {
+      var width=mainButtons.clientWidth-(parseFloat(style.paddingLeft) || 0)-(parseFloat(style.paddingRight) || 0);
+      var height=mainButtons.clientHeight-(parseFloat(style.paddingTop) || 0)-(parseFloat(style.paddingBottom) || 0);
+      var rowHeight=parseFloat(style.getPropertyValue('--account-row-height')) || 84;
+      if(width > 0 && height > 0) {
+        var columns=Math.max(1,Math.floor((width+8)/(240+8)));
+        var rows=Math.max(1,Math.floor((height+8)/(rowHeight+8)));
+        mainButtons.style.setProperty('--account-columns',columns);
+        mainButtons.style.setProperty('--account-rows',rows);
+        return columns*rows;
+      }
+      return parseInt(style.getPropertyValue('--accounts-per-page'), 10) || 42;
+    }
+    return parseInt(style.getPropertyValue('--buttons-per-page'), 10) || 25;
+  }
+  function selectButtonPage(id) {
+    allElements('#TopButtons .page').forEach(function(button) {
+      button.classList.remove('currentpage');
+      if(button.id === String(id)) button.classList.add('currentpage');
+    });
+    hideElements('.Pagina');
+    showElement('#Page'+id);
+    first('#MainButtons').scrollTop=0;
+    fillVisibleText('.Buttontext:visible');
+  }
+  function selectionSnapshot() {
+    if(!pagedButtons || !allElements('#MainButtons .Pagina').length) return null;
+    var activePage=allElements('#TopButtons .currentpage')[0];
+    return {
+      view: pagedButtons,
+      firstIndex: (activePage ? Number(activePage.id)-1 : 0)*pagedButtons.capacity,
+      extras: allElements('#TopButtons .Knopje').filter(function(button) {
+        return !button.classList.contains('page');
+      }),
+    };
+  }
+  function restoreExtraButtons(snapshot) {
+    snapshot.extras.slice().reverse().forEach(function(button) {
+      first('#TopButtons').prepend(button);
+    });
+  }
+  function restoreSelection(snapshot) {
+    if(!snapshot) return;
+    makepages(snapshot.view.className,snapshot.view.buttons,snapshot.view.accountList);
+    selectButtonPage(Math.floor(snapshot.firstIndex/pagedButtons.capacity)+1);
+    restoreExtraButtons(snapshot);
+    if(currentView === 'accounts') addArchiveTabs();
+  }
+  function reflowButtonPages() {
+    if(!pagedButtons || !allElements('#MainButtons .Pagina').length) return;
+    var capacity=buttonsPerPage(pagedButtons.accountList);
+    if(capacity === pagedButtons.capacity) return;
+    var previousFilter=filterRestore;
+    restoreSelection(selectionSnapshot());
+    filterRestore=previousFilter;
+  }
+  function commandButton(extraclass,stuff) {
+    var knopje=appendChildren(createElement('div',{class: "Knopje Knop "+stuff.text+" "+extraclass + " " + stuff.class,id: stuff.text, role: 'button', tabindex: '0', title: stuff.display }), [
+      createElement('span',{class: "Buttontext ",text: stuff.display}),
+    ]);
+    if(stuff.hint) knopje.appendChild(createElement('small',{class:'command-hint',text:stuff.hint}));
+    if(stuff.right) {
+      var fullwidth='';
+      if(!stuff.left && stuff.fill) fullwidth=' fullwidthButton ';
+      appendToElement(knopje, createElement('span',{class: "right extra "+fullwidth+stuff.rightclass,text: stuff.right}));
+    }
+    if(stuff.left) {
+      appendToElement(knopje, createElement('span',{class: "left extra "+stuff.leftclass,text: stuff.left}));
+    }
+    if(window.HackBankButtonStyles && stuff.class && stuff.class.indexOf('account ') === 0) {
+      window.HackBankButtonStyles.apply(knopje,stuff.text,buttonThemes[stuff.text]);
+    }
+    return knopje;
+  }
+  function makeAccountList(accountButtons) {
+    makepages('normal',accountButtons,true);
+  }
+  function reuseAccountGrid(extraclass,buttons,capacity) {
+    if(!pagedButtons || !pagedButtons.accountList || pagedButtons.className !== extraclass || pagedButtons.capacity !== capacity) return null;
+    if(!allElements('#MainButtons #AccountList').length || buttons.length !== pagedButtons.buttons.length) return null;
+    var nodes=allElements('#MainButtons .account');
+    if(nodes.length !== buttons.length) return null;
+    var fields=['text','display','class','left','leftclass','hint','fill'];
+    if(!buttons.every(function(button,index) {
+      return fields.every(function(field) { return button[field] === pagedButtons.buttons[index][field]; });
+    })) return null;
+    return nodes;
+  }
+  function makepages(extraclass,buttons,accountList) {
+    stopAudio();
+    if(!filtering) filterRestore=null;
+    var capacity=buttonsPerPage(accountList);
+    var reused=accountList ? reuseAccountGrid(extraclass,buttons,capacity) : null;
+    var currentPage=allElements('#TopButtons .currentpage')[0];
+    var selectedPage=reused && currentPage ? currentPage.id : '1';
+    var previous=pagedButtons;
+    pagedButtons={className: extraclass, buttons: buttons, capacity: capacity, accountList: !!accountList};
     var pagecount=1;
-    clearElement('#MainButtons');
+    if(!reused) {
+      clearElement('#MainButtons');
+      first('#MainButtons').scrollTop=0;
+    }
     clearElement('#TopButtons');
-    appendTo('#MainButtons', createElement('div',{id: 'Page'+pagecount, class: 'Pagina'}));
-    appendTo('#TopButtons', topButton("page",'1',"Page 1"));
+    var container='#MainButtons';
+    var pageClass='Pagina';
+    if(accountList) {
+      if(!reused) appendTo(container,createElement('div',{id: 'AccountList', role: 'group', 'aria-label': 'Accounts'}));
+      container='#AccountList';
+      pageClass+=' account-list';
+    }
+    if(!reused) appendTo(container, createElement('div',{id: 'Page'+pagecount, class: pageClass}));
+    appendTo('#TopButtons', topButton('page'+(selectedPage === '1' ? ' currentpage' : ''),'1',"Page 1"));
     var counter=0;
     var donewpage=0;
-    var first=1;
+    var firstOnPage=1;
     var lastchar="";
-    buttons.forEach(function(stuff) {
+    buttons.forEach(function(stuff,index) {
       if(donewpage === 1) {
         appendToPageLabel(pagecount, ' - '+lastchar);
         pagecount++;
-        appendTo('#MainButtons', createElement('div',{id: 'Page'+pagecount, class: 'Pagina', style: {display: 'none'}}));
-        appendTo('#TopButtons', topButton("page",String(pagecount),"Page "+pagecount));
+        if(!reused) appendTo(container, createElement('div',{id: 'Page'+pagecount, class: pageClass, style: {display: 'none'}}));
+        appendTo('#TopButtons', topButton('page'+(selectedPage === String(pagecount) ? ' currentpage' : ''),String(pagecount),"Page "+pagecount));
         counter=0;
         donewpage=0;
-        first=1;
+        firstOnPage=1;
       }
-      if(first === 1) {
+      if(firstOnPage === 1) {
         appendToPageLabel(pagecount, '
'+stuff.display.charAt(0).toUpperCase()); - first=0; - } - var knopje=appendChildren(createElement('div',{class: "Knopje Knop "+stuff.text+" "+extraclass + " " + stuff.class,id: stuff.text }), [ - createElement('span',{class: "Buttontext ",text: stuff.display}), - ]); - if(stuff.right) { - var fullwidth=''; - if(!stuff.left && stuff.fill) { - fullwidth=' fullwidthButton '; - } - appendToElement(knopje, createElement('span',{class: "right extra "+fullwidth+stuff.rightclass,text: stuff.right})); - } - if(stuff.left) { - appendToElement(knopje, createElement('span',{class: "left extra "+stuff.leftclass,text: stuff.left})); + firstOnPage=0; } lastchar=stuff.display.charAt(0).toUpperCase(); - appendTo('#Page'+pagecount, knopje); + if(reused) { + if(stuff.right !== previous.buttons[index].right || stuff.rightclass !== previous.buttons[index].rightclass) { + var balance=reused[index].querySelector('.right'); + if(balance) { + balance.textContent=stuff.right; + balance.className='right extra '+(!stuff.left && stuff.fill ? ' fullwidthButton ' : '')+stuff.rightclass; + } + } + } else appendTo('#Page'+pagecount, commandButton(extraclass,stuff)); counter++; - var w=5; - var h=5; - if(counter>=w*h) { + if(counter>=capacity) { donewpage=1; } }); @@ -356,7 +645,7 @@ document.addEventListener("DOMContentLoaded", function() { } else { appendToPageLabel(pagecount, ' - '+lastchar); fillVisibleText(".Paginatext:visible"); - allElements(".Pagina").forEach(function(page) { + if(!reused) allElements(".Pagina").forEach(function(page) { hideElements('.Pagina'); page.style.display=''; fillVisibleText(".Buttontext:visible"); @@ -365,19 +654,90 @@ document.addEventListener("DOMContentLoaded", function() { }); } } + function makeSoundScreen(soundButtons) { + activateTopButton('sounds'); + currentView='sounds'; + pagedButtons=null; + filterRestore=null; + clearElement('#MainButtons'); + clearElement('#TopButtons'); + showElement('#Secondscreen'); + var screen=createElement('div',{class: 'sound-screen'}); + screen.appendChild(createElement('h1',{class: 'section-title', text: 'Sounds'})); + appendTo('#MainButtons',screen); + var actions=createElement('div',{class: 'sound-actions', 'aria-label': 'Sound commands'}); + soundButtons.forEach(function(button) { actions.appendChild(commandButton('normal',button)); }); + screen.appendChild(actions); + audioControl=window.HackBankAudio.mount(screen); + if(window.HackBankSoundSettings) soundSettingsControl=window.HackBankSoundSettings.mount(screen,{session:session,initialState:soundSettings}); + showquestion(); + fillVisibleText('.Buttontext:visible'); + } + function makeCommandScreen(category) { + commandCategory=category || 'All commands'; + locked=1; + activateTopButton('commands'); + currentView='commands'; + pagedButtons=null; filterRestore=null; + clearElement('#MainButtons'); clearElement('#TopButtons'); + showElement('#Secondscreen'); + appendTo('#TopButtons',actionButton('Abort everything','abort','profile-action abort-all')); + var sections=buttons.commandSections(commands); + var icons={'All commands':'📋',Products:'📦',Users:'👥',Sales:'🧾',Wholesale:'🏬',Market:'🏷️',Misc:'🛠️',Settings:'⚙️'}; + function categoryIcon(element,name) { + element.prepend(createElement('span',{class:'category-icon','aria-hidden':'true',text:icons[name]})); + return element; + } + ['All commands'].concat(sections.map(function(section) { return section.name; }),['Settings']).forEach(function(name) { + var tab=categoryIcon(createElement('button',{type:'button',class:'command-tab'+((category || 'All commands') === name ? ' selected' : ''),text:name,'aria-label':name}),name); + tab.addEventListener('click',function() { makeCommandScreen(name); focus(); }); + appendTo('#TopButtons',tab); + }); + var grid=createElement('div',{class:'command-sections'+(category && category !== 'All commands' ? ' single-section' : '')}); + sections.forEach(function(section) { + if(category && category !== 'All commands' && category !== section.name) return; + if(!section.buttons.length) return; + var card=createElement('section',{class:'command-section section-'+section.name.toLowerCase()}); + card.appendChild(categoryIcon(createElement('h2',{text:section.name}),section.name)); + var commandsGrid=createElement('div',{class:'command-group-buttons'}); + section.buttons.forEach(function(button) { commandsGrid.appendChild(commandButton('normal',button)); }); + card.appendChild(commandsGrid); grid.appendChild(card); + }); + if(preferences && (!category || category === 'All commands' || category === 'Settings')) { + var settings=createElement('section',{class:'command-section section-settings'}); + settings.appendChild(categoryIcon(createElement('h2',{text:'Settings'}),'Settings')); + var settingsGrid=createElement('div',{class:'settings-grid'}); + ['party','dark','banana','dank','confetti'].forEach(function(key) { settingsGrid.appendChild(preferences.toggle(key)); }); + settings.appendChild(settingsGrid); grid.appendChild(settings); + } + appendTo('#MainButtons',grid); + focus(); tabenable=1; + } function dobuttons(msg) { + if(currentView === 'commands' && locked === 1) return; var buttons=JSON.parse(msg); if(msg === '{}' && locked === 0) { + if(currentView === 'sounds') return; showAccountButtons('members','m',1); } else if (buttons['special'] === 'custom') { - activateTopButton('commands'); showElement('#Secondscreen'); + var customButtons=window.HackBankButtons.visibleCommandButtons(buttons['custom']); if(buttons['sort'] === "text") { - buttons['custom'].sort(window.HackBankButtons.compareTextReverse); + customButtons.sort(window.HackBankButtons.compareTextReverse); } else { - buttons['custom'].sort(window.HackBankButtons.compareDisplay); + customButtons.sort(window.HackBankButtons.compareDisplay); + } + var soundMenu=buttons['custom'].some(function(button) { return button.text === 'ns'; }) + && buttons['custom'].some(function(button) { return button.text === 'vergaderen'; }); + if(soundMenu) { + soundButtons=window.HackBankButtons.visibleSoundButtons(customButtons); + makeSoundScreen(soundButtons); + } + else { + currentView='action'; + activateTopButton('commands'); + makepages('normal',customButtons); } - makepages('normal',buttons['custom']); focus(); tabenable=0; } else if (buttons['special'] === 'accounts') { @@ -385,7 +745,7 @@ document.addEventListener("DOMContentLoaded", function() { } else if (buttons['special'] === 'accountsamount') { activateTopButton('members'); showElement('#Secondscreen'); - makepages('normal',window.HackBankButtons.accountsToButtons(accounts,members,nonmembers,'m')); + makeAccountList(window.HackBankButtons.accountsToButtons(accounts,members,nonmembers,'m')); document.getElementById('TopButtons').prepend(topButton("shownumbers",'shownumbers',"Enter amount")); prependCashButton(); focus(); @@ -425,12 +785,9 @@ document.addEventListener("DOMContentLoaded", function() { } function playsound(sound) { var audioElement = document.createElement('audio'); - audioElement.setAttribute('src', 'sounds/'+sound); - audioElement.setAttribute('autoplay', 'autoplay'); - audioElement.load(); - audioElement.addEventListener("load", function() { - audioElement.play(); - },true); + audioElement.setAttribute('src', 'sounds/'+sound.split('/').map(encodeURIComponent).join('/')); + var playback=audioElement.play(); + if(playback && playback.catch) playback.catch(function(error) { console.log('Audio playback unavailable',error); }); } function showquestion() { @@ -452,6 +809,7 @@ document.addEventListener("DOMContentLoaded", function() { scrollToBottom('#Log'); break; case 'receipt': + accountProfile=null; build_receipt(msg); break; case 'totals': @@ -464,7 +822,7 @@ document.addEventListener("DOMContentLoaded", function() { break; case 'infobox': run_infobox(pathParts[6],msg); - setMainButtonsBackground("#d7ffd7"); + setMainButtonsBackground("#e8f3ec"); break; case 'accounts': setupaccounts(pathParts[5],msg); @@ -485,6 +843,18 @@ document.addEventListener("DOMContentLoaded", function() { case 'sound': playsound(msg); break; + case 'deposit_payment': + showDepositPayment(JSON.parse(msg)); + break; + case 'sound_settings': + if(soundSettingsControl) soundSettingsControl.receive(JSON.parse(msg)); + break; + case 'button_studio': + if(buttonStudio) buttonStudio.receive(JSON.parse(msg)); + break; + case 'effects': + if(preferences && JSON.parse(msg).type === 'sale') preferences.sale(); + break; case 'members': members=JSON.parse(msg); break; @@ -494,6 +864,19 @@ document.addEventListener("DOMContentLoaded", function() { case 'commands': setupcommands(msg); break; + case 'activity': + activity=JSON.parse(msg); + if(currentView === 'accounts' && !searchValue()) { + makeAccountSections(accountType,archivedAccounts); prependCashButton(); + } + if(currentView === 'profile') { + // A fresh backend check still guards the actual undo command. + allElements('.undo-user-transaction').forEach(function(button) { button.remove(); }); + } + break; + case 'settings': + if(preferences && pathParts[5] === 'party') preferences.setParty(JSON.parse(msg)); + break; } } function runmsg(path,msg) { @@ -501,6 +884,16 @@ document.addEventListener("DOMContentLoaded", function() { //console.log(elms); if(elms.length < 3) return; switch(elms[2]) { + case 'settings': + if(elms[3] === 'sounds') { + soundSettings=JSON.parse(msg); + if(soundSettingsControl) soundSettingsControl.update(soundSettings); + } + if(elms[3] === 'buttons') { + var design=JSON.parse(msg); + if(design && typeof design.user === 'string') updateButtonTheme(design.user,design.theme); + } + break; case 'session': if(elms.length < 5) return; var sessionID=elms[3]; @@ -518,9 +911,14 @@ document.addEventListener("DOMContentLoaded", function() { if(!tabenable) return; var dingen=searchValue().split(" "); var zoek=dingen[dingen.length-1].toLowerCase(); - if(zoek.length<2) { - return; + if(!zoek.length) { + if(filterReturnView === 'commands') { filterReturnView=null; makeCommandScreen(commandCategory); return; } + restoreSelection(filterRestore); + return; } + if(fill && zoek.length<2) return; + if(!filterReturnView && currentView === 'commands') filterReturnView='commands'; + if(!filterRestore) filterRestore=selectionSnapshot(); var buttons=[]; window.HackBankButtons.addMatchingButtons(buttons, window.HackBankButtons.accountsToButtons(accounts,members,nonmembers,'m'), zoek); window.HackBankButtons.addMatchingButtons(buttons, window.HackBankButtons.accountsToButtons(accounts,members,nonmembers,'o'), zoek); @@ -541,7 +939,16 @@ document.addEventListener("DOMContentLoaded", function() { } }); } - makepages('normal',buttons); + filtering=true; + try { + var accountList=filterRestore ? filterRestore.view.accountList : (tabenable === 2 || (buttons.length > 0 && buttons.every(function(button) { + return button.class && button.class.indexOf('account ') === 0; + }))); + makepages('normal',buttons,accountList); + if(filterRestore) restoreExtraButtons(filterRestore); + } finally { + filtering=false; + } if(buttons.length === 1 && fill) { dingen[dingen.length-1]=buttons[0].text+" "; setSearchValue(dingen.join(" ")); @@ -557,6 +964,13 @@ document.addEventListener("DOMContentLoaded", function() { postmsg('input',searchValue()); setSearchValue(""); } + function submitCommand() { + var command=searchValue().trim(); + if(currentView === 'sounds' && !['ns','vergaderen','vergaderenextreme','vergaderen_extreme','throw','yeet','sounds'].includes(command) && !/^(dark|banana|dank|confetti)mode(on|off)$/.test(command)) currentView='action'; + locked=0; + verwerkinput(); + focus(); + } var delay = (function(){ var timer = 0; return function(callback, ms){ @@ -574,6 +988,8 @@ document.addEventListener("DOMContentLoaded", function() { } function handleNormalClick() { + if(currentView === 'commands') locked=0; + if(currentView !== 'sounds' || !['ns','vergaderen','vergaderenextreme','throw','yeet'].includes(String(this.id))) currentView='action'; var dingen=searchValue().split(" "); dingen[dingen.length-1]=this.id; postmsg('input',dingen.join(" ")); @@ -599,9 +1015,7 @@ document.addEventListener("DOMContentLoaded", function() { } function handlePageClick() { - hideElements('.Pagina'); - showElement('#Page'+this.id); - fillVisibleText(".Buttontext:visible"); + selectButtonPage(this.id); focus(); } @@ -610,15 +1024,26 @@ document.addEventListener("DOMContentLoaded", function() { focus(); } + function showEmbeddedScreen(selector,title,url) { + hideElement('#IRCwindow'); + hideElement('#spacewindow'); + showElement(selector); + var screen=first(selector); + if(screen.childElementCount !== 0) return; + screen.appendChild(createElement('iframe',{src: url, title: title, allow: 'autoplay; encrypted-media; picture-in-picture; fullscreen', allowfullscreen: ''})); + } + function handleMainButtonClick() { + filterReturnView=null; + if(this.id !== 'sounds') currentView='action'; switch(this.id) { case 'members': locked=0; - showAccountButtons(this.id,'m',tabenable); + showAccountButtons(this.id,'m',tabenable || 1); break; case 'otherusers': locked=0; - showAccountButtons(this.id,'o',tabenable); + showAccountButtons(this.id,'o',tabenable || 1); break; case 'products': activateTopButton(this.id); @@ -627,32 +1052,33 @@ document.addEventListener("DOMContentLoaded", function() { focus(); break; case 'commands': - activateTopButton(this.id); locked=0; - makepages('normal',window.HackBankButtons.commandsToButtons(commands).sort(window.HackBankButtons.compareDisplay)); + makeCommandScreen(); + break; + case 'sounds': + locked=0; + postmsg('input','sounds'); + setSearchValue(''); focus(); break; case 'back': + currentView='overview'; activateTopButton(this.id); locked=0; hideElement('#Secondscreen'); focus(); break; case 'irc': + currentView='embedded'; activateTopButton(this.id); locked=0; - showElement('#IRCwindow'); - if(document.getElementById('IRCwindow').childElementCount === 0) { - appendTo('#IRCwindow', createElement('iframe',{src: 'http://kleintje:4200/',frameborder: 0, scrolling: 'no', width: '100%', height: '100%'})); - } + showEmbeddedScreen('#IRCwindow','Music','http://192.168.142.157:3000/'); break; case 'spacecon': + currentView='embedded'; activateTopButton(this.id); locked=0; - showElement('#spacewindow'); - if(document.getElementById('spacewindow').childElementCount === 0) { - appendTo('#spacewindow', createElement('iframe',{src: '/spaceconsole/',frameborder: 0, scrolling: 'no', width: '100%', height: '100%'})); - } + showEmbeddedScreen('#spacewindow','Home Assistant','http://hass.space.hack42.nl:8123/home/overview'); break; case 'knopjes': activateTopButton(this.id); @@ -707,39 +1133,104 @@ document.addEventListener("DOMContentLoaded", function() { } function buildLayout() { - appendTo('#body', createElement('div',{id: 'Firstscreen'})); - appendTo('#body', createElement('div',{id: 'Secondscreen'})); - appendTo('#body', createElement('div',{id: 'IRCwindow'})); - appendTo('#body', createElement('div',{id: 'spacewindow'})); + var header=createElement('header',{class: 'app-header row'}); + var logoAbort=createElement('button',{id: 'LogoAbort', class: 'brand brand-abort col-auto', type: 'button', title: 'Reset current sale', 'aria-label': 'Lovelace Bar — Reset current sale'}); + appendChildren(logoAbort,[ + createElement('img',{class: 'brand-logo', src: 'images/Hack42.png', alt: 'Hack42 · Hackerspace Arnhem', width: '84', height: '56'}), + appendChildren(createElement('span'), [ + createElement('span',{class: 'brand-name', text: 'Lovelace Bar'}), + createElement('span',{class: 'brand-caption', text: 'Drinks @ Hack42'}), + ]), + ]); + logoAbort.addEventListener('click',function() { + currentView='action'; + stopAudio(); + setSearchValue('abort'); + submitCommand(); + }); + header.appendChild(logoAbort); + header.appendChild(createElement('nav',{id: 'LeftButtons', class: 'LeftButtons col', 'aria-label': 'Register sections'})); + appendTo('#body', header); - appendTo('#Firstscreen', createElement('div',{id: 'Receipt', class: 'Receipt'})); - appendTo('#Firstscreen', createElement('div',{id: 'Totals', class: 'Totals'})); - appendTo('#Firstscreen', createElement('div',{id: 'Buttons', class: 'Buttons'})); - appendTo('#Firstscreen', createElement('div',{id: 'Log'})); + var workspace=createElement('main',{class: 'workspace row'}); + var leftColumn=createElement('section',{class: 'workspace-main col', 'aria-label': 'Register'}); + var screens=createElement('div',{class: 'screens'}); + var overview=createElement('div',{id: 'Firstscreen', class: 'overview-screen'}); + overview.appendChild(createElement('h1',{class: 'section-title', text: 'Current sale'})); + overview.appendChild(createElement('div',{id: 'Receipt', class: 'Receipt'})); + overview.appendChild(createElement('div',{id: 'Buttons', class: 'Buttons'})); + screens.appendChild(overview); - var inputWrapper=createElement('div',{id: 'Invoer'}); - inputWrapper.appendChild(createElement('input',{id: 'Zoek', placeholder: "Starting network communication", autocomplete: 'off'})); - appendTo('#Firstscreen', inputWrapper); + var selection=createElement('div',{id: 'Secondscreen', class: 'selection-screen'}); + selection.appendChild(createElement('div',{id: 'TopButtons', class: 'RightButtons', 'aria-label': 'Current options'})); + selection.appendChild(createElement('div',{id: 'MainButtons', class: 'MainButtons'})); + screens.appendChild(selection); + screens.appendChild(createElement('div',{id: 'IRCwindow', class: 'embedded-screen'})); + screens.appendChild(createElement('div',{id: 'spacewindow', class: 'embedded-screen'})); + leftColumn.appendChild(screens); + leftColumn.appendChild(appendChildren(createElement('section',{class: 'activity', 'aria-label': 'Register history'}), [ + createElement('span',{class: 'activity-label', text: 'HISTORY'}), + createElement('div',{id: 'Log', role: 'log', 'aria-live': 'polite'}), + ])); + workspace.appendChild(leftColumn); - appendTo('#Secondscreen', createElement('div',{id: 'LeftButtons', class: 'LeftButtons'})); - appendTo('#Secondscreen', createElement('div',{id: 'MainButtons', class: 'MainButtons'})); - appendTo('#Secondscreen', createElement('div',{id: 'TopButtons', class: 'RightButtons'})); - appendTo('#Secondscreen', createElement('div',{id: 'Receipt2'})); + var receiptPanel=createElement('aside',{class: 'receipt-panel col-auto', 'aria-label': 'Sale receipt'}); + receiptPanel.appendChild(appendChildren(createElement('div',{class: 'receipt-heading'}), [ + createElement('h2',{text: 'On the tab'}), + createElement('span',{class: 'receipt-currency', text: 'EUR'}), + ])); + receiptPanel.appendChild(appendChildren(createElement('div',{class: 'receipt-columns', 'aria-hidden': 'true'}), [ + createElement('span',{text: 'ITEM'}), + createElement('span',{text: 'QTY'}), + createElement('span',{text: 'AMOUNT'}), + ])); + receiptPanel.appendChild(createElement('div',{id: 'Receipt2'})); + receiptPanel.appendChild(createElement('div',{id: 'Totals', class: 'Totals', 'aria-label': 'Account totals', 'aria-live': 'polite'})); + receiptPanel.appendChild(appendChildren(createElement('div',{class: 'receipt-footer'}), [ + createElement('span',{class: 'receipt-barcode', 'aria-hidden': 'true'}), + createElement('span',{text: 'Eat. Drink. Hack. Repeat.'}), + ])); + workspace.appendChild(receiptPanel); + appendTo('#body', workspace); + + var inputWrapper=createElement('footer',{id: 'Invoer'}); + inputWrapper.appendChild(appendChildren(createElement('div',{class: 'command-caption'}), [ + createElement('label',{'for': 'Zoek', text: 'COMMAND LINE'}), + createElement('span',{class: 'command-help', text: 'Scan a barcode · Type a command · Tab to complete'}), + ])); + inputWrapper.appendChild(appendChildren(createElement('div',{class: 'command-input'}), [ + createElement('span',{class: 'command-prompt', text: '›_', 'aria-hidden': 'true'}), + createElement('input',{id: 'Zoek', placeholder: "Starting network communication", autocomplete: 'off', spellcheck: 'false', autocapitalize: 'off', 'aria-label': 'Product, command or username'}), + createElement('button',{id: 'CommandEnter', type: 'button', class: 'enter-hint', text: 'ENTER ↵', 'aria-label': 'Submit command'}), + ])); + appendTo('#body', inputWrapper); } function buildLeftButtons() { + var icons={ + abort: '', + members: '', + otherusers: '', + products: '', + commands: '', + sounds: '', + irc: '', + spacecon: '', + }; var buttons=[ - ["Knopje Button topknop normal abort",'abort',"Abort"], + ["Knopje Button topknop normal abort",'abort',"Cancel"], ["Knopje Button topknop special",'members',"Members"], - ["Knopje Button topknop special",'otherusers',"Other Users"], + ["Knopje Button topknop special",'otherusers',"Guests"], ["Knopje Button topknop special",'products',"Products"], ["Knopje Button topknop special",'commands',"Commands"], ["Knopje Button topknop sounds",'sounds',"Sounds"], - ["Knopje Button topknop irc",'irc',"IRC"], - ["Knopje Button topknop spacecon",'spacecon',"Lights"], + ["Knopje Button topknop irc",'irc',"Music"], + ["Knopje Button topknop spacecon",'spacecon',"Home Assistant"], ]; buttons.forEach(function(button) { - appendTo('#LeftButtons', buttonElement(button[0],button[1],button[2])); + var element=buttonElement(button[0],button[1],button[2]); + element.appendChild(createElement('span',{class: 'nav-icon', 'aria-hidden': 'true', html: ''+icons[button[1]]+''})); + appendTo('#LeftButtons', element); }); } @@ -759,20 +1250,20 @@ document.addEventListener("DOMContentLoaded", function() { } function bindSearchInput() { + document.getElementById('CommandEnter').addEventListener('click',submitCommand); searchInput().addEventListener("keydown", function(data) { if(data.which === 9) { dotabfill(1); data.preventDefault(); } else if(data.which === 13) { - locked=0; - postmsg('input',this.value); - this.value=""; - } else { - delay(function(){ - dotabfill(0); - }, 200 ); + submitCommand(); } }); + searchInput().addEventListener('input',function() { + delay(function() { + dotabfill(0); + },200); + }); } window.HackBankStream.createSessionStream({ @@ -788,6 +1279,28 @@ document.addEventListener("DOMContentLoaded", function() { fillVisibleText(".Knopjetext:visible"); bindButtonEvents(); bindSearchInput(); + if(window.HackBankIdleFocus) window.HackBankIdleFocus.watch({ + getInput:searchInput, + isPaused:function() { return currentView === 'embedded'; }, + }); + document.body.addEventListener('keydown', function(event) { + if(event.target.id === 'Zoek' || (event.key !== 'Enter' && event.key !== ' ')) return; + var button=event.target.closest && event.target.closest('.Knopje'); + if(button) { + event.preventDefault(); + button.click(); + } + }); + if(window.ResizeObserver) { + var textObserver=new window.ResizeObserver(function() { + reflowButtonPages(); + fillVisibleText('.Buttontext:visible'); + fillVisibleText('.Knopjetext:visible'); + fillVisibleText('.Paginatext:visible'); + }); + textObserver.observe(document.getElementById('MainButtons')); + textObserver.observe(document.getElementById('LeftButtons')); + } focus(); postmsg('input',''); }); diff --git a/www/kassa-audio.js b/www/kassa-audio.js new file mode 100644 index 0000000..8e41b34 --- /dev/null +++ b/www/kassa-audio.js @@ -0,0 +1,166 @@ +(function(window, document) { + function element(tag, className, text) { + var node=document.createElement(tag); + node.className=className; + if(text) node.textContent=text; + return node; + } + + function mount(container) { + var stopped=false; + var readInFlight=false; + var writeChannel=null; + var revision=0; + var sendTimer=null; + var pending={pos: null, space: null}; + var dragging={pos: false, space: false}; + var controls={}; + var panel=element('section','audio-controls'); + panel.setAttribute('aria-label','Audio volumes'); + var cards=element('div','audio-volume-grid'); + var status=element('p','audio-status','Reading audio levels…'); + status.setAttribute('role','status'); + panel.appendChild(cards); + panel.appendChild(status); + container.appendChild(panel); + + function message(text) { + if(status.textContent !== text) status.textContent=text; + } + function showLevel(control, value, muted) { + control.input.value=String(Math.min(100,value)); + control.output.textContent=muted ? 'Muted' : value+'%'; + control.input.setAttribute('aria-valuetext',muted ? 'Muted, '+value+' percent' : value+' percent'); + control.input.style.setProperty('--volume-fill',Math.min(100,value)+'%'); + } + function apply(state, confirmed) { + if(stopped) return; + if(!state || !state.channels) throw new Error('Invalid audio state'); + var available=0; + Object.keys(controls).forEach(function(id) { + var channel=state.channels[id]; + var control=controls[id]; + if(!channel || !channel.available || !Number.isFinite(channel.volume)) { + control.input.disabled=true; + control.output.textContent='Unavailable'; + control.input.setAttribute('aria-valuetext','Unavailable'); + return; + } + available++; + control.input.disabled=false; + if(dragging[id] || pending[id] !== null || (writeChannel === id && confirmed !== id)) return; + showLevel(control,channel.volume,channel.muted); + }); + message(available === 2 ? 'Volumes are up to date.' : (available ? 'One audio output is unavailable.' : 'Audio controls are unavailable.')); + } + function unavailable() { + if(stopped) return; + Object.keys(controls).forEach(function(id) { + controls[id].input.disabled=true; + controls[id].output.textContent='Unavailable'; + controls[id].input.setAttribute('aria-valuetext','Unavailable'); + }); + message('Audio controls are unavailable.'); + } + function json(response) { + if(!response.ok) throw new Error('Audio request failed'); + return response.json(); + } + function hasPending() { + return Object.keys(pending).some(function(id) { return pending[id] !== null; }); + } + function refresh() { + if(stopped || readInFlight || writeChannel || hasPending()) return; + var requestedRevision=revision; + readInFlight=true; + window.fetch('audio.php',{cache: 'no-store'}).then(json).then(function(state) { + if(requestedRevision === revision) apply(state); + }).catch(function() { + if(requestedRevision === revision) unavailable(); + }).finally(function() { readInFlight=false; }); + } + function sendNext() { + if(stopped || writeChannel) return; + var id=Object.keys(pending).find(function(key) { return pending[key] !== null; }); + if(!id) return; + var change=pending[id]; + pending[id]=null; + writeChannel=id; + message('Saving volume…'); + window.fetch('audio.php',{ + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({channel: id, volume: change.volume, preview: change.preview}), + }).then(json).then(function(state) { + apply(state,id); + if(!stopped && state.preview_error) message('Volume saved; preview sound is unavailable.'); + }).catch(function() { + pending[id]=null; + unavailable(); + }).finally(function() { + writeChannel=null; + if(!stopped) { + if(hasPending()) sendNext(); + else refresh(); + } + }); + } + function queue(id, immediate) { + var control=controls[id]; + if(control.input.disabled || stopped) return; + var value=Number(control.input.value); + pending[id]={volume: value, preview: immediate}; + revision++; + showLevel(control,value,false); + window.clearTimeout(sendTimer); + if(immediate) sendNext(); + else sendTimer=window.setTimeout(sendNext,120); + } + + [{id: 'pos', label: 'HackBank POS effects', hint: 'Scans, confirmations and alerts'}, + {id: 'space', label: 'Space audio', hint: 'Building sound system'}].forEach(function(channel) { + var card=element('div','audio-volume-card'); + var heading=element('div','audio-volume-heading'); + var label=element('label','audio-volume-label',channel.label); + label.setAttribute('for','Volume-'+channel.id); + var output=element('output','audio-volume-value','…'); + output.setAttribute('for','Volume-'+channel.id); + heading.appendChild(label); + heading.appendChild(output); + var input=element('input','audio-slider'); + input.type='range'; + input.id='Volume-'+channel.id; + input.min='0'; + input.max='100'; + input.step='1'; + input.value='0'; + input.disabled=true; + controls[channel.id]={input: input, output: output}; + input.addEventListener('input',function() { queue(channel.id,false); }); + input.addEventListener('change',function() { queue(channel.id,true); }); + input.addEventListener('pointerdown',function() { dragging[channel.id]=true; }); + ['pointerup','pointercancel','blur'].forEach(function(event) { + input.addEventListener(event,function() { dragging[channel.id]=false; }); + }); + card.appendChild(heading); + card.appendChild(element('p','audio-volume-hint',channel.hint)); + card.appendChild(input); + var scale=element('div','audio-volume-scale'); + scale.appendChild(element('span','','Silent')); + scale.appendChild(element('span','','100%')); + card.appendChild(scale); + cards.appendChild(card); + }); + refresh(); + var pollTimer=window.setInterval(refresh,2000); + return { + stop: function() { + stopped=true; + window.clearInterval(pollTimer); + window.clearTimeout(sendTimer); + }, + }; + } + + window.HackBankAudio={mount: mount}; +})(window, document); diff --git a/www/kassa-button-shaders.js b/www/kassa-button-shaders.js new file mode 100644 index 0000000..4b3c53a --- /dev/null +++ b/www/kassa-button-shaders.js @@ -0,0 +1,336 @@ +/* Ambient scenes and local shader adaptations share one WebGL context. */ +(function(window, document) { + var WIDTH=320, HEIGHT=128, FRAME_MS=1000/24; + var VERTEX='attribute vec2 position; varying vec2 uv; void main() { uv=position*.5+.5; gl_Position=vec4(position,0.,1.); }'; + var HEADER=` + #ifdef GL_FRAGMENT_PRECISION_HIGH + precision highp float; + #else + precision mediump float; + #endif + varying vec2 uv; + uniform vec2 resolution; + uniform float time, intensity; + uniform vec3 color0, color1, color2; + vec3 palette(float n) { + return mix(mix(color0,color1,smoothstep(0.,.5,n)),color2,smoothstep(.5,1.,n)); + } + float hash(vec2 p) { return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); } + `; + var SCENES={ + 'shader-matrix':` + // Original texture-free code rain. Each small glyph is a grid of strokes. + float glyph(vec2 cell,vec2 point) { + vec2 pixel=floor(point*vec2(5.,7.)); + float code=hash(cell+floor(time*2.)); + float vertical=step(.52,hash(vec2(pixel.x,code*71.))); + float horizontal=step(.63,hash(vec2(pixel.y,code*113.))); + float stroke=max(vertical*step(1.,pixel.y)*step(pixel.y,5.), + horizontal*step(1.,pixel.x)*step(pixel.x,3.)); + vec2 inset=fract(point*vec2(5.,7.)); + return stroke*step(.12,inset.x)*step(inset.x,.88)*step(.12,inset.y)*step(inset.y,.88); + } + vec3 scene() { + vec2 grid=vec2(resolution.x/resolution.y*9.,9.); + vec2 p=vec2(uv.x,1.-uv.y)*grid; + vec2 cell=floor(p), point=fract(p); + float column=hash(vec2(cell.x,17.)); + float period=15.+floor(column*12.); + float head=time*(2.2+column*2.8)+column*period; + float behind=mod(head-cell.y,period); + float trail=pow(max(0.,1.-behind/11.),1.6); + float tip=1.-smoothstep(0.,1.2,behind); + float ink=glyph(cell,point)*trail; + vec3 rain=mix(color1,color2,tip)*ink; + return color0*.4+rain+color1*trail*.045; + } + `, + 'shader-lights':` + vec3 scene() { + vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.); + vec3 light=color0*.12; + for(int i=0;i<5;i++) { + float n=float(i); + vec2 center=vec2(sin(time*.19+n*1.7)*resolution.x/resolution.y*.34,cos(time*.23+n*2.1)*.28); + vec2 delta=p-center; + light+=palette(n*.25)*(.025/(dot(delta,delta)+.025))*.85; + } + return light; + } + `, + 'shader-snow':` + vec3 scene() { + vec3 sky=mix(color0,color1,uv.y*.65)*.55; + float snow=0.; + for(int i=0;i<4;i++) { + float layer=float(i), scale=5.+layer*3.; + vec2 p=uv*vec2(resolution.x/resolution.y,1.)*scale; + p+=vec2(time*.11/(layer+1.),time*(.22+layer*.09)); + vec2 cell=floor(p); + vec2 center=vec2(hash(cell+layer),hash(cell+layer+17.)); + vec2 flake=fract(p)-(.2+.6*center); + flake.x+=sin(time*.3+center.y*6.283)*.1; + snow+=(1.-smoothstep(.025,.07,length(flake)))*(.35+layer*.18); + } + return sky+color2*snow; + } + `, + 'shader-cubes':` + vec3 scene() { + vec2 p=uv*vec2(resolution.x/resolution.y,1.)*2.4; + p.x+=mod(floor(p.y),2.)*.5; + vec2 cell=floor(p), q=(fract(p)-.5)*1.8; + // A quarter turn travels from the bottom left across successive cells. + float phase=time*.22-cell.x*.15-cell.y*.19; + float angle=(floor(phase)+smoothstep(.12,.84,fract(phase)))*1.570796; + float c=cos(angle), s=sin(angle); + mat3 rotation=mat3(c,0.,-s,0.,1.,0.,s,0.,c); + vec3 camera=normalize(vec3(1.4,1.1,2.3)); + vec3 right=normalize(vec3(camera.z,0.,-camera.x)), up=cross(camera,right); + vec3 origin=rotation*(camera*3.+right*q.x+up*q.y); + vec3 ray=rotation*(-camera), inverse=1./(ray+vec3(.00001)); + vec3 a=(-.36-origin)*inverse, b=(.36-origin)*inverse; + vec3 nearPlane=min(a,b), farPlane=max(a,b); + float entry=max(nearPlane.x,max(nearPlane.y,nearPlane.z)); + float exit=min(farPlane.x,min(farPlane.y,farPlane.z)); + vec3 background=color0*.18; + if(entry>exit) return background; + vec3 hit=origin+ray*entry, extent=abs(hit), normal=vec3(0.); + if(extent.x>extent.y && extent.x>extent.z) normal.x=sign(hit.x); + else if(extent.y>extent.z) normal.y=sign(hit.y); + else normal.z=sign(hit.z); + vec3 face=mix(color0*.65+color1*.2,color1,abs(normal.x)); + face=mix(face,color2,abs(normal.y)); + float light=.55+.45*max(0.,dot(normal*rotation,normalize(vec3(-.4,.8,1.)))); + return mix(background,face*light,smoothstep(0.,.035,exit-entry)); + } + `, + 'shader-synthwave':` + float peak(vec2 cell) { return .25+hash(cell)*3.6; } + float terrain(vec2 p) { + vec2 grid=p*.65, cell=floor(grid), f=fract(grid); + float a=peak(cell), b=peak(cell+vec2(1.,0.)); + float c=peak(cell+vec2(0.,1.)), d=peak(cell+vec2(1.,1.)); + float height=f.x+f.y<1. ? a+(b-a)*f.x+(c-a)*f.y : d+(c-d)*(1.-f.x)+(b-d)*(1.-f.y); + return height*smoothstep(1.,3.8,abs(p.x))-.35; + } + vec3 scene() { + float horizon=.48; + vec3 sky=color0*.28+color1*exp(-abs(uv.y-horizon)*6.)*.32; + vec2 sun=(uv-vec2(.72,.73))*vec2(resolution.x/resolution.y,1.); + float disc=1.-smoothstep(.225,.235,length(sun)); + float stripes=smoothstep(.08,.18,fract(sun.y*22.)); + disc*=mix(stripes,1.,step(0.,sun.y)); + sky=mix(sky,mix(color1,color2,clamp((uv.y-.4)*2.8,0.,1.)),disc); + vec3 camera=vec3(0.,1.1,time*2.2); + vec3 ray=normalize(vec3((uv.x-.5)*resolution.x/resolution.y,(uv.y-horizon)*1.25,.95)); + float distance=.1; + for(int i=0;i<36;i++) { + vec3 point=camera+ray*distance; + float gap=point.y-terrain(point.xz); + if(gap<.025) { + vec2 f=fract(point.xz*.65), lines=min(f,1.-f); + float hills=smoothstep(1.,3.8,abs(point.x)); + float edge=min(lines.x,lines.y); + edge=min(edge,mix(1.,abs(f.x+f.y-1.)*.707,hills)); + float width=.012+distance*.0008; + float neon=1.-smoothstep(width,width*2.,edge); + float roadEdge=1.-smoothstep(.025,.07,abs(abs(point.x)-1.15)); + vec3 ground=color0*.18+color1*(.12*hills+neon*.85)+color2*roadEdge*.45; + return mix(sky,ground,exp(-distance*.038)); + } + distance+=max(.055,gap*.45); + if(distance>38.) break; + } + return sky+color1*exp(-abs(uv.y-horizon)*140.)*.5; + } + `, + }; + Object.assign(SCENES,window.HackBankExtraButtonShaders || {}); + var FOOTER='void main() { gl_FragColor=vec4(clamp(mix(palette(uv.y*.6+uv.x*.4),scene(),intensity),0.,1.),1.); }'; + var source=null, gl=null, programs={}, state='idle'; + var targets=new Map(), observer=null, sizes=null, mutations=null, observing=false; + var motion=null, timer=null, frame=null; + + function seedFor(user) { + var hash=2166136261; + for(var index=0;index>>0)/4294967296; + } + function palette(background) { + var stops=background.gradient.stops.slice().sort(function(a,b) { return a.position-b.position; }); + return [stops[0],stops[Math.floor((stops.length-1)/2)],stops[stops.length-1]]; + } + function announce(value) { + state=value; + document.dispatchEvent(new window.CustomEvent('buttonshaderstatus')); + } + function stop() { + window.clearTimeout(timer); timer=null; + if(frame !== null) window.cancelAnimationFrame(frame); + frame=null; + } + function mark(target,value) { target.canvas.dataset.shaderState=value; } + function fallback() { + stop(); targets.forEach(function(target) { mark(target,'fallback'); }); + } + function initialize() { + if(state !== 'idle') return state === 'ready'; + try { + if(!source) { + source=document.createElement('canvas'); source.width=WIDTH; source.height=HEIGHT; + source.addEventListener('webglcontextlost',function(event) { + event.preventDefault(); announce('lost'); fallback(); + }); + source.addEventListener('webglcontextrestored',function() { + programs={}; gl=null; state='idle'; + if(initialize()) schedule(); + }); + } + // Keep the small shared buffer valid while Firefox copies it into each + // button. Discarding it can expose empty frames on some GPU backends. + gl=source.getContext('webgl',{alpha:false,depth:false,stencil:false,antialias:false,preserveDrawingBuffer:true,powerPreference:'low-power'}); + if(!gl) throw new Error('WebGL unavailable'); + var buffer=gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER,buffer); + gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0,2,gl.FLOAT,false,0,0); + announce('ready'); return true; + } catch(_error) { announce('unavailable'); fallback(); return false; } + } + function program(kind) { + if(Object.prototype.hasOwnProperty.call(programs,kind)) return programs[kind]; + var vertex=gl.createShader(gl.VERTEX_SHADER), fragment=gl.createShader(gl.FRAGMENT_SHADER); + gl.shaderSource(vertex,VERTEX); gl.compileShader(vertex); + gl.shaderSource(fragment,HEADER+SCENES[kind]+FOOTER); gl.compileShader(fragment); + var handle=gl.createProgram(); + gl.attachShader(handle,vertex); gl.attachShader(handle,fragment); + gl.bindAttribLocation(handle,0,'position'); gl.linkProgram(handle); + gl.deleteShader(vertex); gl.deleteShader(fragment); + if(!gl.getProgramParameter(handle,gl.LINK_STATUS)) { + console.warn('Ambient button shader unavailable:',kind,gl.getProgramInfoLog(handle)); + gl.deleteProgram(handle); programs[kind]=null; + document.dispatchEvent(new window.CustomEvent('buttonshaderstatus')); + return null; + } + var uniforms={}; + ['resolution','time','intensity','color0','color1','color2'].forEach(function(name) { uniforms[name]=gl.getUniformLocation(handle,name); }); + programs[kind]={handle:handle,uniforms:uniforms}; return programs[kind]; + } + function measure(target,width,height) { + if(width <= 0 || height <= 0) return; + var scale=Math.min(1,WIDTH/width,HEIGHT/height); + target.width=Math.max(1,Math.round(width*scale)); target.height=Math.max(1,Math.round(height*scale)); + if(target.canvas.width !== target.width) target.canvas.width=target.width; + if(target.canvas.height !== target.height) target.canvas.height=target.height; + } + function visibleTargets() { + var dialog=document.querySelector('dialog[open]'); + var quiet=document.body.classList.contains('button-styles-off'); + return Array.from(targets.values()).filter(function(target) { + return target.canvas.isConnected && target.visible && target.width && target.height && (!dialog || dialog.contains(target.canvas)) + && (!quiet || !target.canvas.closest('.magic-button') || target.canvas.closest('.magic-preview')); + }); + } + function schedule() { + if(frame !== null || timer !== null || document.hidden || state !== 'ready') return; + frame=window.requestAnimationFrame(draw); + } + function draw(timestamp) { + frame=null; + if(document.hidden || state !== 'ready') return; + var visible=visibleTargets(); + if(!visible.length) return; + var groups=new Map(); + visible.forEach(function(target) { + var key=target.key+':'+target.width+':'+target.height; + if(!groups.has(key)) groups.set(key,[]); + groups.get(key).push(target); + }); + var rendered=false; + try { + groups.forEach(function(group) { + var target=group[0], settings=target.settings, compiled=program(settings.kind); + if(!compiled) { group.forEach(function(item) { mark(item,'fallback'); }); return; } + rendered=true; + gl.viewport(0,0,target.width,target.height); gl.useProgram(compiled.handle); + var uniforms=compiled.uniforms; + gl.uniform2f(uniforms.resolution,target.width,target.height); + gl.uniform1f(uniforms.time,(motion.matches ? 12 : timestamp/1000*24/settings.speed)+target.seed*120); + gl.uniform1f(uniforms.intensity,settings.intensity/100); + target.colors.forEach(function(color,index) { gl.uniform3fv(uniforms['color'+index],color); }); + gl.drawArrays(gl.TRIANGLES,0,3); + group.forEach(function(item) { + item.context.drawImage(source,0,HEIGHT-item.height,item.width,item.height,0,0,item.width,item.height); + mark(item,motion.matches ? 'still' : 'animated'); + }); + }); + } catch(_error) { announce('unavailable'); fallback(); return; } + if(rendered && !motion.matches) timer=window.setTimeout(function() { timer=null; schedule(); },FRAME_MS); + } + function cleanup() { + targets.forEach(function(target,canvas) { + if(canvas.isConnected) return; + if(observer) observer.unobserve(canvas); + if(sizes) sizes.unobserve(canvas); + targets.delete(canvas); + }); + if(!targets.size) { stop(); mutations.disconnect(); observing=false; } + else schedule(); + } + function watch() { + if(!motion) { + motion=window.matchMedia('(prefers-reduced-motion: reduce)'); + motion.addEventListener('change',function() { stop(); schedule(); document.dispatchEvent(new window.CustomEvent('buttonshaderstatus')); }); + document.addEventListener('visibilitychange',function() { stop(); if(!document.hidden) schedule(); }); + document.addEventListener('buttonstyleschange',function() { stop(); schedule(); }); + if(window.IntersectionObserver) observer=new window.IntersectionObserver(function(entries) { + entries.forEach(function(entry) { + var target=targets.get(entry.target); + if(target) target.visible=entry.isIntersecting && entry.intersectionRect.width > 0 && entry.intersectionRect.height > 0; + }); + schedule(); + }); + sizes=new window.ResizeObserver(function(entries) { + entries.forEach(function(entry) { + var target=targets.get(entry.target); + if(target) measure(target,entry.contentRect.width,entry.contentRect.height); + }); + schedule(); + }); + mutations=new window.MutationObserver(cleanup); + } + if(!observing) { mutations.observe(document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['open']}); observing=true; } + } + function attach(layer,background,user) { + var canvas=layer.querySelector('.magic-shader-canvas'); + if(!Object.prototype.hasOwnProperty.call(SCENES,background.kind)) { + if(canvas) canvas.remove(); + return; + } + var previous=canvas && targets.get(canvas); + if(!canvas) { + canvas=document.createElement('canvas'); canvas.className='magic-shader-canvas'; + canvas.setAttribute('aria-hidden','true'); canvas.dataset.shaderState='fallback'; + layer.appendChild(canvas); + } + var context=canvas.getContext('2d',{alpha:false}); + if(!context) return; + watch(); + var settings=JSON.parse(JSON.stringify(background)); + var colors=palette(settings).map(function(stop) { + return [1,3,5].map(function(offset) { return parseInt(stop.color.slice(offset,offset+2),16)/255; }); + }); + var seed=seedFor(String(user || '')); + var target={canvas:canvas,context:context,settings:settings,colors:colors,seed:seed,key:JSON.stringify([settings.kind,colors,settings.speed,settings.intensity,seed]),visible:previous ? previous.visible : !observer,width:previous ? previous.width : 0,height:previous ? previous.height : 0}; + targets.set(canvas,target); + var rect=layer.getBoundingClientRect(); measure(target,rect.width,rect.height); + if(!previous) { + if(observer) observer.observe(canvas); + sizes.observe(canvas); + } + if(initialize()) schedule(); + } + window.HackBankButtonShaders={attach:attach,palette:palette,status:function(kind) { return programs[kind] === null ? 'unavailable' : state; }}; +})(window, document); diff --git a/www/kassa-button-studio.css b/www/kassa-button-studio.css new file mode 100644 index 0000000..5c992a1 --- /dev/null +++ b/www/kassa-button-studio.css @@ -0,0 +1,119 @@ +/* The studio owns its colours, type and motion; the POS behind it stays put. */ +.button-studio { --studio-purple: #704ac3; --studio-ink: #35294d; --studio-muted: #716582; width: min(1510px, calc(100vw - 64px)); max-width: none; height: min(900px, calc(100dvh - 64px)); max-height: none; padding: 0; border: 2px solid #ffffff; border-radius: 24px; color: var(--studio-ink); background: #fcfaff; box-shadow: 0 28px 100px #0c082a60; font: 16px/1.45 system-ui, sans-serif; overflow: hidden; } +.button-studio[open] { display: flex; flex-direction: column; animation: studio-arrive 220ms ease-out; } +.button-studio::backdrop { background: #101526b8; backdrop-filter: blur(5px); } +.button-studio *, .button-studio *::before, .button-studio *::after { box-sizing: border-box; } +.button-studio h1, .button-studio h2, .button-studio h3, .button-studio p { margin: 0; } +.button-studio button, .button-studio input, .button-studio select { font: inherit; } +.button-studio button { cursor: pointer; } +.button-studio button:disabled { opacity: .45; cursor: default; } +.button-studio :focus-visible { outline: 3px solid #8e61dd; outline-offset: 3px; } +.studio-header { display: flex; flex: 0 0 auto; align-items: center; gap: 20px; padding: 24px 30px; border-bottom: 1px solid #e6dcf2; background: linear-gradient(110deg, #f1e7ff, #fff5df 70%, #e6faf0); } +.studio-header h1 { font-size: clamp(21px, 1.8vw, 30px); font-weight: 850; letter-spacing: -.8px; } +.studio-header .studio-hint { margin-top: 3px; } +.studio-badge { display: grid; place-items: center; flex: 0 0 62px; height: 62px; border: 3px solid #fff; border-radius: 20px; color: #fff; background: #8960d1; box-shadow: 4px 5px 0 #cdb6ed; font-size: 42px; transform: rotate(-9deg); } +.studio-eyebrow { color: #8e63ae; font-size: 10px; font-weight: 800; letter-spacing: 2px; } +.studio-close { flex: 0 0 48px; height: 48px; margin-left: auto; border: 1px solid #cdbce0; border-radius: 50%; color: #645078; background: #ffffffb3; font-size: 30px !important; } +.studio-main { display: grid; flex: 1 1 0; grid-template-columns: 390px minmax(0, 1fr); min-height: 0; } +.studio-workbench { position: relative; display: flex; flex-direction: column; align-items: center; padding: 28px 24px; border-right: 1px solid #e6dcf2; overflow: auto; background: radial-gradient(#cfc1e5 1px, transparent 1px) 0 0 / 16px 16px, #f0eafa; } +.studio-owner { align-self: flex-start; margin-bottom: 24px; padding: 6px 13px; background: #fff9d1; color: #8b6c21; border: 1px solid #ead591; border-radius: 4px; font-size: 13px; font-weight: 750; transform: rotate(-3deg); } +.studio-workbench h2 { align-self: flex-start; margin: 0 0 10px; font-size: 14px; } +.studio-preview-slot { width: 100%; padding: 16px; border: 1px solid #dbd0e9; border-radius: 14px; background: #ffffffb8; box-shadow: 0 4px 0 #dcd0ed; } +.studio-new { border: 2px solid #a487e0; box-shadow: 0 5px 0 #c6b2e9, 0 10px 25px #704ac314; } +.magic-preview.Knop { width: 100%; height: 84px; min-height: 84px; max-height: 84px; justify-content: space-between; padding: 8px 12px; text-align: left; transform: none !important; box-shadow: none !important; cursor: default; } +.magic-preview .extra { position: static; max-width: 45%; padding: 4px 8px; font-size: 14px; border-radius: 5px; } +.studio-magic-arrow { margin: 20px 0; color: #9a7aba; font-size: 13px; font-weight: 750; letter-spacing: 1px; } +.studio-live { margin-top: 16px; color: #756094; font-size: 10px; font-weight: 850; letter-spacing: 2px; } +.studio-hint { color: var(--studio-muted); font-size: 12px; line-height: 1.5; } +.studio-workbench > .studio-hint { margin: 8px 0 20px; text-align: center; } +.studio-draft-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; } +.studio-sticker { display: grid; place-items: center; flex: 0 0 auto; width: 94px; height: 94px; margin-top: 28px; border: 2px dashed #debc5c; border-radius: 50%; background: #ffe88d; color: #8b681f; white-space: pre-line; text-align: center; font-size: 12px; line-height: 1.6; font-weight: 850; transform: rotate(10deg); } +.studio-editing { display: flex; flex-direction: column; min-width: 0; min-height: 0; } +.studio-tabs { display: flex; flex: 0 0 auto; gap: 8px; padding: 20px 28px 16px; border-bottom: 1px solid #e6dcf2; background: #fff; } +.studio-tab { flex: 1 1 0; min-height: 52px; padding: 8px 16px; border: 1px solid #e0d5ec; border-radius: 12px; color: #7c678c; background: #f8f4fc; font-size: 14px !important; font-weight: 750 !important; } +.studio-tab[aria-selected="true"] { border-color: #b394df; background: #eee3ff; color: #663db2; box-shadow: 0 3px 0 #d7c4ef; } +.studio-controls { display: flex; flex: 1 1 0; min-width: 0; min-height: 0; margin: 0; padding: 0; border: 0; } +.studio-editor { flex: 1 1 0; min-height: 0; padding: 22px 30px 30px; overflow-y: auto; overscroll-behavior: contain; scrollbar-color: #cbb9df #f7f1fb; } +.studio-scroll-tools { display: flex; flex: 0 0 auto; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 28px; border-top: 1px solid #eee5f5; background: #fcfaff; } +.studio-scroll-tools .studio-hint { margin-right: auto; } +.studio-editor h2 { margin: 0 0 18px; font-size: 18px; } +.studio-editor h3 { margin: 0 0 12px; font-size: 15px; } +.studio-presets { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin-bottom: 22px; } +.studio-preset { display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 6px 4px; border: 2px solid transparent; border-radius: 10px; color: #6e5b81; background: transparent; font-size: 11px !important; font-weight: 650 !important; } +.studio-preset:hover { background: #f1e7ff; } +.studio-preset[aria-pressed="true"] { border-color: #b394df; background: #f1e7ff; } +.studio-swatch { position: relative; display: grid; place-items: center; width: 100%; height: 48px; overflow: hidden; border: 2px solid #fff; border-radius: 12px; box-shadow: 0 2px 6px #46306026; font-size: 23px; } +.studio-shader-fallback { color: #85541b; } +.studio-field { display: flex; flex-direction: column; flex: 1 1 0; min-width: 0; gap: 7px; margin: 0 0 16px; } +.studio-field-name { color: #6d5c7d; font-size: 12px; font-weight: 750; } +.studio-input { width: 100%; min-width: 0; min-height: 46px; padding: 10px 12px; border: 1px solid #d9cae6; border-radius: 9px; color: #4c3b61; background: #fff; } +.studio-input:invalid { border-color: #d15a79; } +.studio-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +.studio-color-row { display: flex; gap: 10px; } +.studio-color-picker { flex: 0 0 52px; width: 52px; height: 46px; padding: 3px; border: 1px solid #d9cae6; border-radius: 9px; background: #fff; cursor: pointer; } +.studio-hex { font-family: ui-monospace, monospace !important; text-transform: uppercase; } +.studio-gradient { padding: 18px; margin: 0 0 18px; border: 1px solid #e3d8ee; border-radius: 14px; background: #f8f4fc; } +.studio-gradient-track { height: 26px; margin-bottom: 18px; border: 2px solid #fff; border-radius: 20px; box-shadow: 0 1px 4px #44305b1a; } +.studio-stop { display: grid; grid-template-columns: minmax(0, 1fr) 110px 44px; gap: 12px; align-items: center; } +.studio-stop .studio-field { margin-bottom: 12px; } +.studio-stop-remove { align-self: end; width: 44px; height: 46px; margin-bottom: 12px; border: 1px solid #e1ccdf; border-radius: 8px; color: #ac668b; background: #fff; font-size: 22px !important; } +.studio-small-button { min-height: 44px; padding: 8px 13px; border: 1px solid #d7c8e7; border-radius: 9px; color: #745293; background: #fff; font-size: 12px !important; font-weight: 750 !important; } +.studio-small-button:hover { background: #f2eaff; } +.studio-gradient > .studio-hint { margin-top: 10px; } +.studio-range-row { display: flex; align-items: center; gap: 12px; min-height: 46px; } +.studio-range-row input[type="range"] { flex: 1 1 0; min-width: 0; height: 40px; margin: 0; accent-color: #9163cb; cursor: pointer; } +.studio-number { width: 68px; height: 44px; padding: 5px; border: 1px solid #d9cae6; border-radius: 8px; text-align: center; color: #6b478d; background: #fff; } +.studio-unit { flex: 0 0 26px; font-size: 12px; color: #8c799c; } +.studio-editor > .studio-hint { margin: -4px 0 18px; } +.studio-drop { display: flex; flex-direction: column; align-items: center; gap: 10px; margin: 12px 0 18px; padding: 28px; border: 2px dashed #c6acdf; border-radius: 14px; background: #f2eafb; text-align: center; } +.studio-drop.drag-over { background: #e3d2ff; border-color: #8a55cc; } +.studio-drop-star { font-size: 30px; } +.studio-file { display: none; } +.studio-footer { display: flex; flex: 0 0 auto; align-items: center; gap: 14px; padding: 18px 28px; border-top: 1px solid #e6dcf2; background: #fff; } +.studio-save-note { flex: 1 1 0; } +.studio-status { color: #6c4a91; font-size: 13px; font-weight: 700; } +.studio-save-note .studio-hint { margin-top: 3px; font-size: 11px; } +.studio-save-note .studio-small-button { margin-top: 8px; } +.studio-save-note [hidden] { display: none; } +.studio-cancel { min-height: 52px; padding: 10px 20px; border: 1px solid #ded1e9; border-radius: 11px; background: #fff; color: #786584; font-size: 14px !important; font-weight: 650 !important; } +.studio-save { min-height: 54px; padding: 12px 24px; border: 1px solid #7042ac; border-radius: 12px; color: #fff; background: #8957cc; box-shadow: 0 4px 0 #62359b; font-size: 15px !important; font-weight: 800 !important; } +.studio-save:hover:not(:disabled) { background: #7945bf; transform: translateY(-1px); } +.studio-saved .studio-new { animation: studio-ta-da 600ms ease; } +@keyframes studio-arrive { from { opacity: 0; transform: translateY(12px) scale(.985); } to { opacity: 1; transform: none; } } +@keyframes studio-ta-da { 40% { transform: rotate(-2deg) scale(1.04); box-shadow: 0 0 0 12px #d6efcf80; } 70% { transform: rotate(1deg); } } +@media (max-width: 1100px) { + .button-studio { width: calc(100vw - 24px); height: calc(100dvh - 24px); } + .studio-main { grid-template-columns: 310px minmax(0,1fr); } + .studio-workbench { padding: 22px 16px; } + .studio-preview-slot { padding: 10px; } + .studio-presets { grid-template-columns: repeat(3,minmax(0,1fr)); } + .studio-editor { padding: 20px; } + .studio-tabs { padding: 16px; } + .studio-tab { padding: 8px; } +} +@media (max-width: 760px) { + .studio-header { gap: 12px; padding: 16px; } + .studio-header h1 { font-size: 18px; letter-spacing: -.5px; } + .studio-badge { flex-basis: 42px; height: 42px; font-size: 28px; border-radius: 12px; } + .studio-header .studio-hint, .studio-eyebrow { display: none; } + .studio-main { display: block; overflow-y: auto; } + .studio-workbench { border: 0; padding: 16px; } + .studio-owner, .studio-sticker, .studio-draft-actions, .studio-workbench > .studio-hint { display: none; } + .studio-preview-slot { max-width: 350px; } + .studio-magic-arrow { margin: 12px 0; } + .studio-workbench h2 { align-self: center; } + .studio-tabs { position: sticky; top: 0; z-index: 3; } + .studio-editing, .studio-controls, .studio-editor { display: block; } + .studio-scroll-tools { display: none; } + .studio-field-grid { grid-template-columns: 1fr; gap: 0; } + .studio-stop { grid-template-columns: minmax(0,1fr) 82px 44px; gap: 8px; } + .studio-stop .studio-color-row { gap: 5px; } + .studio-stop .studio-color-picker { flex-basis: 38px; width: 38px; } + .studio-footer { flex-wrap: wrap; gap: 10px; padding: 12px 16px; } + .studio-save-note { flex-basis: 100%; } + .studio-save-note .studio-hint { display: none; } + .studio-save { flex-grow: 1; } +} +@media (prefers-reduced-motion: reduce) { + .button-studio, .button-studio * { animation: none !important; transition: none !important; } +} diff --git a/www/kassa-button-studio.js b/www/kassa-button-studio.js new file mode 100644 index 0000000..477a3d8 --- /dev/null +++ b/www/kassa-button-studio.js @@ -0,0 +1,386 @@ +(function(window, document) { + var serial=0; + function create(options) { + var styles=window.HackBankButtonStyles; + var dialog=null, profile=null, draft=null, current=null, pending=null, timeout=null; + var editor, currentSlot, newSlot, status, save, retry, tabs, controls, returnFocus, shaderNote; + var selectedTab='Background', ready=false, processing=false, dirty=false, generation=0; + + function el(tag,className,text) { + var node=document.createElement(tag); node.className=className; + if(text !== undefined) node.textContent=text; + return node; + } + function button(text,className,callback) { + var node=el('button',className,text); node.type='button'; + node.addEventListener('click',callback); return node; + } + function hint(text) { return el('p','studio-hint',text); } + function message(text) { if(status) status.textContent=text; } + function shaderStatus() { + if(!shaderNote) return; + var renderer=window.HackBankButtonShaders, state=renderer ? renderer.status(draft.background.kind) : 'unavailable'; + shaderNote.hidden=!['unavailable','lost'].includes(state) && !window.matchMedia('(prefers-reduced-motion: reduce)').matches; + shaderNote.textContent=['unavailable','lost'].includes(state) + ? 'Animation is unavailable here. Your colour gradient is still shown, and you can keep designing.' + : (window.matchMedia('(prefers-reduced-motion: reduce)').matches + ? 'Reduced motion is on. These scenes keep a still preview.' + : ''); + shaderNote.classList.toggle('studio-shader-fallback',['unavailable','lost'].includes(state)); + } + function refreshButtons() { + save.disabled=!ready || !!pending || processing; + controls.disabled=!!pending || processing; + retry.hidden=ready || !!pending; + } + function preview() { + var design=!dirty && !options.getTheme(profile.user) ? null : draft; + var button=newSlot.querySelector('.magic-preview'); + if(button) styles.apply(button,profile.user,design); + else newSlot.appendChild(styles.preview(profile.user,profile.amount,design)); + } + function changed() { + dirty=true; preview(); message('Looking magical. Save when you’re happy!'); + } + function inputField(parent,title,type,value,attributes,onInput) { + var label=el('label','studio-field'); + label.appendChild(el('span','studio-field-name',title)); + var input=el('input','studio-input'); input.type=type; input.value=value; + input.id='Studio-field-'+(++serial); + Object.keys(attributes || {}).forEach(function(key) { input.setAttribute(key,attributes[key]); }); + input.addEventListener('input',function() { onInput(input.value,input); }); + label.appendChild(input); parent.appendChild(label); return input; + } + function range(parent,title,value,min,max,unit,onInput) { + var field=el('div','studio-field studio-range'); + var label=el('label','studio-field-name',title); + var row=el('div','studio-range-row'); + var input=el('input',''); input.type='range'; input.min=min; input.max=max; input.step=1; input.value=value; + input.id='Studio-range-'+(++serial); label.htmlFor=input.id; + var numeric=el('input','studio-number'); numeric.type='number'; numeric.min=min; numeric.max=max; numeric.step=1; numeric.value=value; + numeric.setAttribute('aria-label',title+' value'); + function update(source,other) { + if(!source.checkValidity() || source.value === '') return; + other.value=source.value; onInput(Number(source.value)); changed(); + } + input.addEventListener('input',function() { update(input,numeric); }); + numeric.addEventListener('input',function() { update(numeric,input); }); + row.appendChild(input); row.appendChild(numeric); row.appendChild(el('span','studio-unit',unit)); + field.appendChild(label); field.appendChild(row); parent.appendChild(field); + } + function select(parent,title,value,choices,onChange) { + var field=el('div','studio-field'); + var label=el('label','studio-field-name',title); + var input=el('select','studio-input'); + input.id='Studio-select-'+(++serial); label.htmlFor=input.id; + Object.keys(choices).forEach(function(key) { var option=el('option','',choices[key]); option.value=key; input.appendChild(option); }); + input.value=value; + input.addEventListener('change',function() { onChange(input.value); changed(); }); + field.appendChild(label); field.appendChild(input); parent.appendChild(field); return input; + } + function color(parent,title,value,onInput) { + var field=el('div','studio-field studio-color'); + field.appendChild(el('span','studio-field-name',title)); + var row=el('div','studio-color-row'); + var picker=el('input','studio-color-picker'); picker.type='color'; picker.value=value; + picker.setAttribute('aria-label',title+' colour picker'); + var hex=el('input','studio-input studio-hex'); hex.value=value; hex.spellcheck=false; hex.maxLength=7; + hex.setAttribute('aria-label',title+' hex'); hex.pattern='#[A-Fa-f0-9]{6}'; hex.required=true; + picker.addEventListener('input',function() { hex.value=picker.value; onInput(picker.value); changed(); }); + hex.addEventListener('input',function() { if(hex.checkValidity()) { picker.value=hex.value; onInput(hex.value); changed(); } }); + row.appendChild(picker); row.appendChild(hex); field.appendChild(row); parent.appendChild(field); + } + function gradient(parent,value,title) { + var section=el('section','studio-gradient'); section.setAttribute('aria-label',title); + section.appendChild(el('h3','',title)); + var track=el('div','studio-gradient-track'); + function repaint() { track.style.background=styles.cssGradient(value); } + repaint(); section.appendChild(track); + range(section,title+' angle',value.angle,0,360,'°',function(angle) { value.angle=angle; repaint(); }); + value.stops.forEach(function(stop,index) { + var row=el('div','studio-stop'); + color(row,'Stop '+(index+1),stop.color,function(newColor) { stop.color=newColor; repaint(); }); + var position=inputField(row,'Position %','number',stop.position,{min:0,max:100,step:1,required:''},function(next,input) { + if(input.checkValidity() && next !== '') { stop.position=Number(next); repaint(); changed(); } + }); + position.setAttribute('aria-label',title+' stop '+(index+1)+' position'); + var remove=button('×','studio-stop-remove',function() { value.stops.splice(index,1); renderEditor(); changed(); }); + remove.disabled=value.stops.length <= 2; + remove.setAttribute('aria-label','Remove '+title.toLowerCase()+' stop '+(index+1)); + row.appendChild(remove); section.appendChild(row); + }); + var add=button('+ Add colour stop','studio-small-button',function() { + value.stops.push({color:'#ffffff',position:50}); renderEditor(); changed(); + }); + add.disabled=value.stops.length >= 6; section.appendChild(add); + section.appendChild(hint('Stops are sorted by percentage. Put two stops at the same % for a sharp edge.')); + parent.appendChild(section); + } + function backgroundPanel() { + var settings=editor.querySelector('.studio-background-settings'); + if(!settings) { + editor.appendChild(el('h2','','Pick your kind of magic')); + var presets=el('div','studio-presets'); + presets.setAttribute('aria-label','Background presets'); + styles.presets.forEach(function(preset,index) { + var shader=preset.kind.startsWith('shader-'); + var tile=button('', 'studio-preset',function() { + var next=styles.preset(index); draft.background=next.background; draft.text.color=next.text.color; + if(shader && !draft.text.backdrop) draft.text.backdrop=next.text.backdrop; + renderEditor(); changed(); + }); + tile.dataset.preset=index; + var swatch=el('span','studio-swatch',shader ? '' : preset.emoji); + swatch.setAttribute('aria-hidden','true'); + var design=styles.preset(index); + swatch.style.background=design.background.kind === 'solid' ? design.background.color : styles.cssGradient(design.background.gradient); + if(shader && window.HackBankButtonShaders) window.HackBankButtonShaders.attach(swatch,design.background,profile.user); + tile.appendChild(swatch); tile.appendChild(el('span','',preset.name)); presets.appendChild(tile); + }); + editor.appendChild(presets); + shaderNote=hint(''); shaderNote.setAttribute('role','status'); editor.appendChild(shaderNote); + settings=el('div','studio-background-settings'); editor.appendChild(settings); + } + settings.textContent=''; + editor.querySelectorAll('.studio-preset').forEach(function(tile) { + var background=styles.preset(Number(tile.dataset.preset)).background; + tile.setAttribute('aria-pressed',String(background.kind === draft.background.kind && JSON.stringify(background.gradient) === JSON.stringify(draft.background.gradient) && background.color === draft.background.color)); + }); + shaderStatus(); + var choices={solid:'Solid paint',gradient:'Colour gradient',glitter:'Glitter ✨',plasma:'Plasma 🪐'}; + styles.presets.forEach(function(preset) { if(preset.kind.startsWith('shader-')) choices[preset.kind]=preset.name; }); + select(settings,'Background style',draft.background.kind,choices,function(kind) { + draft.background.kind=kind; renderEditor(); + }); + var shader=draft.background.kind.startsWith('shader-'); + if(draft.background.kind === 'solid') color(settings,'Background',draft.background.color,function(value) { draft.background.color=value; }); + else if(shader && window.HackBankButtonShaders) { + var palette=el('section','studio-gradient'); palette.appendChild(el('h3','','Scene colours')); + window.HackBankButtonShaders.palette(draft.background).filter(function(stop,index,all) { return all.indexOf(stop) === index; }).forEach(function(stop,index) { + color(palette,'Scene colour '+(index+1),stop.color,function(value) { stop.color=value; }); + }); + palette.appendChild(hint('Your palette also becomes the still background when animation is unavailable.')); + settings.appendChild(palette); + } + else gradient(settings,draft.background.gradient,'Background gradient'); + if(shader || ['glitter','plasma'].includes(draft.background.kind)) { + var row=el('div','studio-field-grid'); + range(row,'Effect cycle',draft.background.speed,2,60,'sec',function(value) { draft.background.speed=value; }); + range(row,'Magic intensity',draft.background.intensity,0,100,'%',function(value) { draft.background.intensity=value; }); + settings.appendChild(row); settings.appendChild(hint('Longer cycles move more slowly. All the sparkles stay inside your button.')); + } + } + function textPanel() { + editor.appendChild(el('h2','','Give your name some personality')); + var row=el('div','studio-field-grid'); + select(row,'Font',draft.text.font,{system:'Clean & friendly',rounded:'Round & bouncy',comic:'Comic time',mono:'Terminal hacker',serif:'Storybook',condensed:'Big & bold',cyber:'Cyber'},function(value) { draft.text.font=value; }); + select(row,'Weight',String(draft.text.weight),{'400':'Regular','600':'Semibold','700':'Bold','900':'Extra bold'},function(value) { draft.text.weight=Number(value); }); + editor.appendChild(row); + select(editor,'Name case',draft.text.case || 'original',styles.cases,function(value) { draft.text.case=value; }); + range(editor,'Font size',draft.text.size,10,48,'px',function(value) { draft.text.size=value; }); + range(editor,'Text angle',draft.text.angle,0,360,'°',function(value) { draft.text.angle=value; }); + range(editor,'Text rotation',draft.text.rotation,0,60,'sec',function(value) { draft.text.rotation=value; }); + editor.appendChild(hint('Rotation: 0 is still. A higher number means a slower full turn.')); + select(editor,'Text paint',draft.text.gradient ? 'gradient' : 'solid',{solid:'One colour',gradient:'Rainbow letters'},function(value) { + draft.text.gradient=value === 'gradient' ? styles.gradient(['#fff3a6','#ff9bdb','#a7edff'],90) : null; + renderEditor(); + }); + if(draft.text.gradient) gradient(editor,draft.text.gradient,'Text gradient'); + else color(editor,'Text',draft.text.color,function(value) { draft.text.color=value; }); + var backdrop=el('section','studio-gradient'); + backdrop.appendChild(el('h3','','A little contrast for your letters')); + select(backdrop,'Label backdrop',draft.text.backdrop ? 'on' : 'off',{off:'No backdrop',on:'Colour backdrop'},function(value) { + draft.text.backdrop=value === 'on' ? {color:'#100c3d',opacity:75,padding:5,radius:5} : null; + renderEditor(); + }); + if(draft.text.backdrop) { + color(backdrop,'Backdrop colour',draft.text.backdrop.color,function(value) { draft.text.backdrop.color=value; }); + range(backdrop,'Backdrop opacity',draft.text.backdrop.opacity,0,100,'%',function(value) { draft.text.backdrop.opacity=value; }); + var backdropShape=el('div','studio-field-grid'); + range(backdropShape,'Backdrop padding',draft.text.backdrop.padding,0,12,'px',function(value) { draft.text.backdrop.padding=value; }); + range(backdropShape,'Backdrop rounding',draft.text.backdrop.radius,0,20,'px',function(value) { draft.text.backdrop.radius=value; }); + backdrop.appendChild(backdropShape); + } + editor.appendChild(backdrop); + } + function loadImage(file) { + if(!file || !/^image\/(png|jpeg|gif|webp)$/.test(file.type)) { message('Choose a PNG, JPEG, GIF or WebP image.'); return; } + if(file.size > 8*1024*1024) { message('That image is a little big. Choose one under 8 MB.'); return; } + var ticket=++generation; + processing=true; refreshButtons(); message('Making your image button-sized…'); + var url=window.URL.createObjectURL(file); + var image=new window.Image(); + function finished() { window.URL.revokeObjectURL(url); if(ticket === generation) { processing=false; refreshButtons(); } } + image.onload=function() { + if(ticket !== generation || !dialog) { finished(); return; } + try { + if(!image.naturalWidth || !image.naturalHeight || image.naturalWidth > 16000 || image.naturalHeight > 16000) throw new Error('Image too large'); + var canvas=document.createElement('canvas'); canvas.width=144; canvas.height=144; + var size=Math.min(image.naturalWidth,image.naturalHeight); + canvas.getContext('2d').drawImage(image,(image.naturalWidth-size)/2,(image.naturalHeight-size)/2,size,size,0,0,144,144); + var png=canvas.toDataURL('image/png'); + if(png.length > 128*1024) throw new Error('Image too large'); + draft.icon.image=png; draft.icon.kind='image'; + finished(); renderEditor(); changed(); message('Image ready! Save to keep it with your button.'); + } catch(_error) { finished(); message('This image could not be resized. Please try another one.'); } + }; + image.onerror=function() { finished(); if(ticket === generation) message('This image could not be opened. Please try another one.'); }; + image.src=url; + } + function iconPanel() { + editor.appendChild(el('h2','','A tiny picture. A big entrance.')); + select(editor,'Icon',draft.icon.kind,{none:'No icon',image:'My own image',key:'🔑 Key',star:'★ Star',bolt:'ϟ Lightning',heart:'♥ Heart',smile:'☺ Happy face'},function(value) { + draft.icon.kind=value; + if(value === 'image' && !draft.icon.image) message('Choose or paste an image to finish your icon.'); + renderEditor(); + }); + var drop=el('div','studio-drop'); drop.tabIndex=0; drop.setAttribute('aria-label','Paste or drop an icon image'); + drop.appendChild(el('span','studio-drop-star','🖼️')); + drop.appendChild(el('strong','','Your image goes here')); + drop.appendChild(hint('Drop an image, or focus here and paste with Ctrl+V (⌘V on a Mac).')); + var file=el('input','studio-file'); file.type='file'; file.accept='image/png,image/jpeg,image/webp,image/gif'; + file.setAttribute('aria-label','Upload icon image'); + file.addEventListener('change',function() { loadImage(file.files[0]); }); + drop.appendChild(button('Choose image…','studio-small-button',function() { file.click(); })); drop.appendChild(file); + drop.addEventListener('dragover',function(event) { event.preventDefault(); drop.classList.add('drag-over'); }); + drop.addEventListener('dragleave',function() { drop.classList.remove('drag-over'); }); + drop.addEventListener('drop',function(event) { event.preventDefault(); drop.classList.remove('drag-over'); loadImage(event.dataTransfer.files[0]); }); + editor.appendChild(drop); + if(draft.icon.image) editor.appendChild(button('Remove my image','studio-small-button',function() { draft.icon.image=''; draft.icon.kind='none'; renderEditor(); changed(); })); + editor.appendChild(hint('Images are cropped to a square and fitted with cover. Animated images become a still icon. The original file is not kept.')); + range(editor,'Icon size',draft.icon.size,20,64,'px',function(value) { draft.icon.size=value; }); + range(editor,'Icon rotation',draft.icon.rotation,0,60,'sec',function(value) { draft.icon.rotation=value; }); + editor.appendChild(hint('Rotation: 0 is still. Larger numbers give a slower spin.')); + } + function renderEditor() { + if(selectedTab !== 'Background' || !editor.querySelector('.studio-background-settings')) { + shaderNote=null; + editor.textContent=''; editor.scrollTop=0; + } + tabs.forEach(function(tab) { tab.setAttribute('aria-selected',String(tab.dataset.tab === selectedTab)); }); + editor.setAttribute('aria-labelledby','Studio-tab-'+selectedTab); + if(selectedTab === 'Background') backgroundPanel(); + else if(selectedTab === 'Lettering') textPanel(); + else iconPanel(); + } + function close() { + if(pending && pending.action === 'save') { message('Saving your magic. Just a moment…'); return; } + ++generation; processing=false; + window.clearTimeout(timeout); pending=null; + document.removeEventListener('buttonshaderstatus',shaderStatus); shaderNote=null; + var previous=dialog; dialog=null; + previous.close(); previous.remove(); + if(returnFocus && returnFocus.isConnected) returnFocus.focus(); + } + function fail(id,text) { + if(!dialog || !pending || pending.id !== id) return; + window.clearTimeout(timeout); pending=null; refreshButtons(); message(text); + } + function request(theme) { + if(!dialog || pending) return; + var id='studio-'+Date.now()+'-'+(++serial); + var payload={request:id,user:profile.user}; + if(theme) payload.theme=theme; + pending={id:id,action:theme ? 'save' : 'read'}; + refreshButtons(); message(theme ? 'Saving your new button…' : 'Opening your paint box…'); + timeout=window.setTimeout(function() { fail(id,'No reply from the bar. Your changes are still here; please try again.'); },10000); + window.fetch('post.php',{method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:new URLSearchParams({topic:'session/'+options.session+'/button_studio',msg:JSON.stringify(payload)}) + }).then(function(response) { if(!response.ok) throw new Error('Could not save'); return response.json(); }) + .then(function(reply) { if(reply.done !== 1) throw new Error('Could not save'); }) + .catch(function() { fail(id,'Could not reach the bar. Your changes are still here; please try again.'); }); + } + function receive(reply) { + if(!dialog || !pending || !reply || reply.request !== pending.id) return; + if(!reply.ok) { fail(pending.id,reply.error || 'Your button could not be saved.'); return; } + if(reply.user !== profile.user) { fail(pending.id,'The bar replied for a different user. Please reopen the studio.'); return; } + var action=pending.action; + window.clearTimeout(timeout); pending=null; ready=true; + current=styles.copy(reply.theme || styles.defaults(profile.user)); + if(action === 'read' && !dirty) { draft=styles.copy(current); renderEditor(); preview(); } + currentSlot.textContent=''; currentSlot.appendChild(styles.preview(profile.user,profile.amount,reply.theme)); + refreshButtons(); + if(action === 'save') { + if(options.onSaved) options.onSaved(reply.user,reply.theme); + dialog.classList.add('studio-saved'); message('Ta-da! Your new button is saved.'); + var savedDialog=dialog; + window.setTimeout(function() { if(dialog === savedDialog) close(); },800); + } else message('Your paint box is ready. Have fun!'); + } + function open(userProfile) { + if(dialog) return; + document.addEventListener('buttonshaderstatus',shaderStatus); + profile=userProfile; selectedTab='Background'; dirty=false; ready=false; + returnFocus=document.activeElement; + current=styles.copy(options.getTheme(profile.user) || styles.defaults(profile.user)); draft=styles.copy(current); + dialog=el('dialog','button-studio'); dialog.setAttribute('aria-labelledby','Studio-title'); + var header=el('header','studio-header'); + header.appendChild(el('span','studio-badge','✦')); + var title=el('div',''); title.appendChild(el('span','studio-eyebrow','THE BUTTON MAKEOVER MACHINE')); + var h1=el('h1','','Hack42 Magic Button Studio (tm)'); h1.id='Studio-title'; title.appendChild(h1); + title.appendChild(hint('A tiny button. A very big imagination.')); header.appendChild(title); + var dismiss=button('×','studio-close',close); dismiss.setAttribute('aria-label','Close button studio'); header.appendChild(dismiss); + dialog.appendChild(header); + var main=el('div','studio-main'); + var workbench=el('aside','studio-workbench'); + workbench.appendChild(el('div','studio-owner','✂ Made by '+profile.user)); + workbench.appendChild(el('h2','','Your current button')); + currentSlot=el('div','studio-current studio-preview-slot'); currentSlot.appendChild(styles.preview(profile.user,profile.amount,options.getTheme(profile.user))); workbench.appendChild(currentSlot); + workbench.appendChild(el('div','studio-magic-arrow','↓ a little magic ↓')); + workbench.appendChild(el('h2','','Your new button')); + newSlot=el('div','studio-new studio-preview-slot'); workbench.appendChild(newSlot); + workbench.appendChild(el('span','studio-live','● LIVE PREVIEW')); + workbench.appendChild(hint('Same name. Same balance. All your personality.')); + var resets=el('div','studio-draft-actions'); + resets.appendChild(button('↶ Revert changes','studio-small-button',function() { draft=styles.copy(current); renderEditor(); changed(); })); + resets.appendChild(button('Start fresh','studio-small-button',function() { draft=styles.defaults(profile.user); renderEditor(); changed(); })); + workbench.appendChild(resets); + workbench.appendChild(el('div','studio-sticker','100% YOU\n0% BORING')); + main.appendChild(workbench); + var editing=el('section','studio-editing'); + var tablist=el('div','studio-tabs'); tablist.setAttribute('role','tablist'); tablist.setAttribute('aria-label','Button design tools'); + tabs=['Background','Lettering','Icon'].map(function(name,index) { + var tab=button(['🎨 Background','🔤 Lettering','🖼️ Icon'][index],'studio-tab',function() { selectedTab=name; renderEditor(); }); + tab.id='Studio-tab-'+name; tab.dataset.tab=name; tab.setAttribute('role','tab'); tab.setAttribute('aria-controls','Studio-editor'); + tab.addEventListener('keydown',function(event) { + if(!['ArrowRight','ArrowLeft','Home','End'].includes(event.key)) return; + event.preventDefault(); + var next=event.key === 'Home' ? 0 : (event.key === 'End' ? 2 : (index+(event.key === 'ArrowRight' ? 1 : 2))%3); + tabs[next].click(); tabs[next].focus(); + }); + tablist.appendChild(tab); return tab; + }); + editing.appendChild(tablist); + controls=el('fieldset','studio-controls'); + editor=el('div','studio-editor'); editor.id='Studio-editor'; editor.setAttribute('role','tabpanel'); + controls.appendChild(editor); editing.appendChild(controls); + var scrollTools=el('div','studio-scroll-tools'); + scrollTools.appendChild(hint('More room for more magic')); + scrollTools.appendChild(button('↑ Scroll up','studio-small-button',function() { editor.scrollBy({top:-editor.clientHeight*.75,behavior:'smooth'}); })); + scrollTools.appendChild(button('Scroll down ↓','studio-small-button',function() { editor.scrollBy({top:editor.clientHeight*.75,behavior:'smooth'}); })); + editing.appendChild(scrollTools); main.appendChild(editing); dialog.appendChild(main); + var footer=el('footer','studio-footer'); + var notes=el('div','studio-save-note'); status=el('p','studio-status'); status.setAttribute('role','status'); notes.appendChild(status); + notes.appendChild(hint('Saved on this bar, shared with its terminals. Images stay in the bar’s private data.')); + retry=button('Try loading again','studio-small-button',function() { request(); }); notes.appendChild(retry); + footer.appendChild(notes); footer.appendChild(button('Cancel','studio-cancel',close)); + save=button('✦ Use my new button','studio-save',function() { + var invalid=Array.from(editor.querySelectorAll('input')).find(function(input) { return !input.checkValidity(); }); + if(invalid) { invalid.reportValidity(); return; } + if(draft.icon.kind === 'image' && !draft.icon.image) { selectedTab='Icon'; renderEditor(); message('Choose an image, or select “No icon”, before saving.'); return; } + request(draft); + }); + footer.appendChild(save); dialog.appendChild(footer); + dialog.addEventListener('click',function(event) { event.stopPropagation(); }); + dialog.addEventListener('keydown',function(event) { event.stopPropagation(); }); + dialog.addEventListener('cancel',function(event) { event.preventDefault(); close(); }); + dialog.addEventListener('paste',function(event) { + var item=Array.from(event.clipboardData.items).find(function(part) { return part.kind === 'file' && part.type.startsWith('image/'); }); + if(item) { event.preventDefault(); loadImage(item.getAsFile()); } + }); + document.body.appendChild(dialog); renderEditor(); preview(); refreshButtons(); dialog.showModal(); request(); + } + return {open:open,receive:receive,isOpen:function() { return !!dialog; }}; + } + window.HackBankButtonStudio={create:create}; +})(window, document); diff --git a/www/kassa-button-styles.js b/www/kassa-button-styles.js new file mode 100644 index 0000000..82d16c1 --- /dev/null +++ b/www/kassa-button-styles.js @@ -0,0 +1,220 @@ +(function(window, document) { + var STORAGE_KEY='hackbank-button-styles', enabled=true; + try { enabled=window.localStorage.getItem(STORAGE_KEY) !== 'off'; } catch(_error) { /* The toggle still works without storage. */ } + function updateToggle(button) { + button.textContent=enabled ? '😎' : '🙂'; + button.setAttribute('aria-pressed',String(enabled)); + button.title=enabled ? 'Hide button styles' : 'Show button styles'; + } + function updateAppearance() { + if(document.body) document.body.classList.toggle('button-styles-off',!enabled); + if(document.querySelectorAll) { + document.querySelectorAll('.button-styles-toggle').forEach(updateToggle); + document.querySelectorAll('.magic-button').forEach(updateName); + } + } + function announceAppearance() { + updateAppearance(); + document.dispatchEvent(new window.CustomEvent('buttonstyleschange')); + } + function setEnabled(value) { + enabled=value !== false; + try { window.localStorage.setItem(STORAGE_KEY,enabled ? 'on' : 'off'); } catch(_error) { /* Keep the in-memory preference. */ } + announceAppearance(); + } + function toggleButton() { + var button=node('button','archive-tab button-styles-toggle'); + button.type='button'; + button.setAttribute('aria-label','Button styles'); updateToggle(button); + button.addEventListener('click',function() { setEnabled(!enabled); }); + return button; + } + if(document.addEventListener) document.addEventListener('DOMContentLoaded',updateAppearance); + if(window.addEventListener) window.addEventListener('storage',function(event) { + if(event.key !== STORAGE_KEY && event.key !== null) return; + enabled=event.newValue !== 'off'; announceAppearance(); + }); + var FONTS={system:'system-ui, sans-serif',rounded:'"Arial Rounded MT Bold", "Trebuchet MS", sans-serif', + comic:'"Comic Sans MS", "Comic Sans", cursive',mono:'ui-monospace, "Courier New", monospace', + serif:'Georgia, "Times New Roman", serif',condensed:'Impact, "Arial Narrow", sans-serif', + cyber:'"HackBank Cyber", ui-monospace, "Courier New", monospace'}; + var CASES={original:'Original',uppercase:'UPPERCASE',lowercase:'lowercase',capitalize:'Capitalized Words', + camel:'camelCase',pascal:'PascalCase',snake:'snake_case',kebab:'kebab-case'}; + var SYMBOLS={key:'🔑',star:'★',bolt:'ϟ',heart:'♥',smile:'☺'}; + function formatName(user,mode) { + if(mode === 'uppercase') return user.toUpperCase(); + if(mode === 'lowercase') return user.toLowerCase(); + if(!['capitalize','camel','pascal','snake','kebab'].includes(mode)) return user; + var words=user.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu,'$1 $2') + .replace(/(\p{Ll}\p{M}*|\p{Nd})(\p{Lu})/gu,'$1 $2').split(/[\s_-]+/u).filter(Boolean); + if(!words.length) return user; + words=words.map(function(word) { return word.toLowerCase(); }); + if(mode === 'snake') return words.join('_'); + if(mode === 'kebab') return words.join('-'); + return words.map(function(word,index) { + if(mode === 'camel' && index === 0) return word; + var characters=Array.from(word); + return characters.shift().toUpperCase()+characters.join(''); + }).join(mode === 'capitalize' ? ' ' : ''); + } + function updateName(button) { + var label=button.querySelector('.Buttontext'); + if(!label) return; + var mode=enabled || button.classList.contains('magic-preview') ? button.getAttribute('data-button-case') : 'original'; + label.textContent=formatName(button.getAttribute('data-button-user'),mode); + } + function copy(value) { return JSON.parse(JSON.stringify(value)); } + function gradient(colors,angle) { + return {angle:angle,stops:colors.map(function(color,index) { return {color:color,position:index*100/(colors.length-1)}; })}; + } + function base() { + return {version:1,background:{kind:'solid',color:'#e7edf3',gradient:gradient(['#7760db','#f080b5'],135),speed:24,intensity:65}, + text:{color:'#263e52',gradient:null,size:17,font:'system',weight:700,angle:0,rotation:0,backdrop:null,case:'original'}, + icon:{kind:'none',image:'',size:36,rotation:0}}; + } + var PRESETS=[ + {name:'Blue glitter',emoji:'✨',kind:'glitter',colors:['#102b65','#285c9d','#142e6b'],text:'#ffffff'}, + {name:'Cosmic plasma',emoji:'🪐',kind:'plasma',colors:['#251055','#fa228c','#04d9d2'],text:'#ffffff'}, + {name:'Candy shop',emoji:'🍬',kind:'gradient',colors:['#fdc5de','#c9b5ff','#a5e4f2'],text:'#38254e'}, + {name:'Sunset',emoji:'🌅',kind:'gradient',colors:['#fead63','#ee6f98','#7c5bd4'],text:'#ffffff'}, + {name:'Blue & yellow',emoji:'💙',kind:'gradient',colors:['#0057b7','#0057b7','#ffd700','#ffd700'],text:'#ffffff'}, + {name:'Fresh paint',emoji:'🎨',kind:'solid',colors:['#6088b0','#9bb9d7'],text:'#ffffff'}, + {name:'Five lights',emoji:'💡',kind:'shader-lights',colors:['#18183c','#ed548a','#6fddd5'],text:'#ffffff',speed:40}, + {name:'Snow',emoji:'❄️',kind:'shader-snow',colors:['#102d48','#416c9b','#e9f6ff'],text:'#ffffff',speed:32}, + {name:'Cubes',emoji:'🧊',kind:'shader-cubes',colors:['#182742','#448eab','#a9d5e2'],text:'#ffffff',speed:44}, + {name:'Synthwave',emoji:'🌴',kind:'shader-synthwave',colors:['#201136','#d749a1','#ffc07b'],text:'#ffffff',speed:40}, + {name:'Wonderful',emoji:'🔮',kind:'shader-wonderful',colors:['#c95bd8','#56e5d1','#ffb36c'],text:'#ffffff',speed:48}, + {name:'Heartfelt',emoji:'💜',kind:'shader-heartfelt',colors:['#171c3b','#415d91','#ffc6a1'],text:'#ffffff',speed:48}, + {name:'Star Nest',emoji:'🌌',kind:'shader-star-nest',colors:['#ff0000','#00ff00','#0000ff'],text:'#ffffff',speed:40}, + {name:'2D Clouds',emoji:'☁️',kind:'shader-clouds',colors:['#336699','#66b3ff','#ffffe6'],text:'#ffffff',speed:36}, + {name:'Universe Within',emoji:'🌠',kind:'shader-universe',colors:['#552d91','#6ccdee','#dfbdf2'],text:'#ffffff',speed:48}, + {name:'Matrix',emoji:'💻',kind:'shader-matrix',colors:['#000804','#008f3b','#b6ffd1'],text:'#ffffff',speed:24}, + ]; + function preset(index) { + var definition=PRESETS[index]; + var theme=base(); + theme.background.kind=definition.kind; + theme.background.color=definition.colors[0]; + theme.background.gradient=gradient(definition.colors,135); + theme.text.color=definition.text; + if(definition.kind.startsWith('shader-')) { + theme.background.speed=definition.speed; theme.background.intensity=90; + theme.text.backdrop={color:definition.kind === 'shader-matrix' ? '#001208' : '#100c26',opacity:65,padding:5,radius:5}; + } + if(index === 1) theme.text.backdrop={color:'#100c3d',opacity:80,padding:5,radius:5}; + if(index === 4) { + theme.background.gradient={angle:180,stops:[{color:'#0057b7',position:0},{color:'#0057b7',position:50},{color:'#ffd700',position:50},{color:'#ffd700',position:100}]}; + theme.text.color='#102d48'; + theme.text.backdrop={color:'#fffdf3',opacity:95,padding:5,radius:5}; + } + return theme; + } + function defaults(user) { + switch(user.toLowerCase()) { + case 'stitch': return preset(5); + case 'bugblue': return preset(0); + case 'anus': return preset(1); + case 'nino': return preset(4); + case 'jos': { + var theme=base(); + theme.background.kind='gradient'; + theme.background.gradient=gradient(['#263649','#182533'],120); + theme.text.color='#fff8e7'; + theme.icon.kind='key'; + return theme; + } + default: return base(); + } + } + function cssGradient(value) { + return 'linear-gradient('+value.angle+'deg, '+value.stops.slice().sort(function(a,b) { return a.position-b.position; }).map(function(stop) { + return stop.color+' '+stop.position+'%'; + }).join(', ')+')'; + } + function node(tag,className) { + var element=document.createElement(tag); + element.className=className; + return element; + } + function apply(button,user,design) { + var theme=design || defaults(user); + var label=button.querySelector('.Buttontext'); + if(!label) return; + button.appendChild(label); + button.querySelectorAll('.magic-label, .magic-icon').forEach(function(element) { element.remove(); }); + button.classList.add('magic-button'); + button.classList.toggle('magic-neutral',!design && !['stitch','bugblue','anus','nino','jos'].includes(user.toLowerCase())); + button.setAttribute('data-button-user',user); + button.setAttribute('data-button-case',theme.text.case || 'original'); + updateName(button); + var background=theme.background; + var layer=button.querySelector('.magic-layer') || node('span','magic-layer'); + layer.className='magic-layer magic-'+background.kind; + layer.setAttribute('aria-hidden','true'); + layer.style.background=background.kind === 'solid' ? background.color : cssGradient(background.gradient); + layer.style.setProperty('--magic-speed',background.speed+'s'); + layer.style.setProperty('--magic-power',background.intensity/100); + background.gradient.stops.slice(0,3).forEach(function(stop,index) { layer.style.setProperty('--magic-color-'+index,stop.color); }); + if(background.gradient.stops.length < 3) layer.style.setProperty('--magic-color-2',background.gradient.stops[0].color); + button.prepend(layer); + if(window.HackBankButtonShaders) window.HackBankButtonShaders.attach(layer,background,user); + button.style.setProperty('--magic-ink',theme.text.color); + button.style.setProperty('--magic-font',FONTS[theme.text.font] || FONTS.system); + button.style.setProperty('--magic-synthesis',theme.text.font === 'cyber' ? 'weight' : 'none'); + button.style.setProperty('--magic-size',theme.text.size+'px'); + button.style.setProperty('--magic-weight',theme.text.weight); + if(theme.icon.kind !== 'none') { + var icon=node('span','magic-icon'); + icon.setAttribute('aria-hidden','true'); + icon.style.width=theme.icon.size+'px'; + icon.style.height=theme.icon.size+'px'; + icon.style.flexBasis=theme.icon.size+'px'; + var picture; + if(theme.icon.kind === 'image') { + picture=node('img','magic-icon-image'); + picture.src=theme.icon.image; picture.alt=''; + } else { + picture=node('span','magic-symbol'); + picture.textContent=SYMBOLS[theme.icon.kind] || ''; + picture.style.fontSize=theme.icon.size+'px'; + } + if(theme.icon.rotation) { picture.classList.add('magic-spin'); picture.style.animationDuration=theme.icon.rotation+'s'; } + icon.appendChild(picture); + button.appendChild(icon); + } + var frame=node('span','magic-label'); + var angle=node('span','magic-angle'); + angle.style.transform='rotate('+theme.text.angle+'deg)'; + var name=node('span','magic-name'); + var backdrop=theme.text.backdrop; + if(backdrop) { + name.classList.add('has-label-backdrop'); + name.style.setProperty('--label-backdrop-color',backdrop.color); + name.style.setProperty('--label-backdrop-opacity',backdrop.opacity/100); + name.style.setProperty('--label-backdrop-padding',backdrop.padding+'px'); + name.style.setProperty('--label-backdrop-radius',backdrop.radius+'px'); + } + label.style.fontSize=''; + label.classList.remove('magic-spin'); + label.style.animationDuration=''; + if(theme.text.rotation) { name.classList.add('magic-spin'); name.style.animationDuration=theme.text.rotation+'s'; } + label.classList.toggle('magic-text-gradient',!!theme.text.gradient); + label.style.backgroundImage=theme.text.gradient ? cssGradient(theme.text.gradient) : ''; + name.appendChild(label); angle.appendChild(name); frame.appendChild(angle); button.appendChild(frame); + var balance=button.querySelector('.extra'); + if(balance) button.appendChild(balance); + } + function preview(user,amount,theme) { + var button=node('div','Knopje Knop account magic-preview'); + button.setAttribute('role','img'); + button.setAttribute('aria-label',user+' button preview'); + var label=node('span','Buttontext'); label.textContent=user; + var balance=node('span','extra'+(amount < -13.37 ? ' rood' : (amount < 0 ? ' orange' : ''))); + balance.textContent=Number(amount).toFixed(2); + button.appendChild(label); button.appendChild(balance); + apply(button,user,theme); + return button; + } + window.HackBankButtonStyles={apply:apply,preview:preview,defaults:defaults,copy:copy,gradient:gradient,cssGradient:cssGradient,preset:preset,presets:PRESETS,fonts:FONTS,cases:CASES,formatName:formatName, + enabled:function() { return enabled; },setEnabled:setEnabled,toggleButton:toggleButton}; +})(window, document); diff --git a/www/kassa-buttons.js b/www/kassa-buttons.js index 08b3c64..e96d631 100644 --- a/www/kassa-buttons.js +++ b/www/kassa-buttons.js @@ -51,12 +51,63 @@ return buttons; } + function visibleCommandButtons(buttons) { + var hiddenSoundCommands=['deuroff', 'deuron', 'groovesalad', 'blues', 'killsounds', 'louder', 'quieter', 'kassaoff', 'kassaon']; + return buttons.filter(function(button) { + return hiddenSoundCommands.indexOf(button.text) === -1; + }); + } + function commandsToButtons(commands) { var buttons = []; Object.keys(commands).sort().forEach(function(command) { buttons.push({text: command, display: commands[command], class: command}); }); - return buttons; + return visibleCommandButtons(buttons); + } + + function commandSections(commands) { + var definitions=[ + ['Products', [['addproduct','Add new'], ['aliasproduct','Add alias'], ['setprice','Change price'], ['voorraad','Set stock']]], + ['Users', [['deposit','Deposit Money'], ['give','Give Money'], ['take','Take Money'], ['adduseralias','Add user key alias'], ['members','Manage members']]], + ['Sales', [['bon','Print Receipt'], ['kassala','Open cash drawer'], ['undolist','Undo a transaction'], ['undo','Undo last transaction'], ['history','User History']]], + ['Wholesale', [['declaratie','Declaration'], ['printstock','Print stock overview'], ['bons','Receipts to print'], ['remove','Remove item'], ['afroom','Remove cash money'], ['delmember','Remove user from members'], ['inkoop','Report buying'], ['verkoop','Report sales'], ['restore','Restore a transaction'], ['pfand','Return deposit']]], + ['Market', [['market','Products'], ['changemarket','Pricing'], ['addmarket','Aliases'], ['delmarket','Remove product']]], + ['Misc', [['sounds','Sounds'], ['stickers','Stickers'], ['dooropen','Door open']]], + ]; + var hidden=['abort','addmember','partymodeon','partymodeoff','darkmodeon','darkmodeoff','bananamodeon','bananamodeoff','dankmodeon','dankmodeoff','confettimodeon','confettimodeoff','undouserlatest']; + var used=new Set(hidden); + var sections=definitions.map(function(definition) { + return {name: definition[0], buttons: definition[1].filter(function(item) { + used.add(item[0]); + return Object.prototype.hasOwnProperty.call(commands,item[0]); + }).map(function(item) { + return {text: item[0], display: item[1], class: item[0], hint: item[0] === 'remove' ? 'Current, unbilled sale' : ''}; + })}; + }); + var extra=commandsToButtons(commands).filter(function(button) { return !used.has(button.text); }); + sections[sections.length-1].buttons=sections[sections.length-1].buttons.concat(extra); + return sections; + } + + function visibleSoundButtons(buttons) { + return visibleCommandButtons(buttons).filter(function(button) { + return button.text !== 'abort' && button.text !== 'christmas'; + }); + } + + function splitAccounts(candidates, accounts, activity) { + var active=[], archive=[]; + candidates.forEach(function(button) { + var last=activity && activity.last_purchase && activity.last_purchase[button.text]; + if(!last) last=accounts[button.text].lastupdate; + var old=activity && activity.available && /^\d{4}-\d{2}-\d{2}/.test(last || '') && last < activity.cutoff; + if(old) { + button.class+=' archived-account'; + archive.push(button); + } else active.push(button); + }); + return {active: active, archive: archive}; } function accountsToButtons(accounts, members, nonmembers, type) { @@ -147,11 +198,15 @@ allProductsToButtons: allProductsToButtons, buttonMatches: buttonMatches, commandsToButtons: commandsToButtons, + commandSections: commandSections, + visibleSoundButtons: visibleSoundButtons, + splitAccounts: splitAccounts, compareDisplay: compareDisplay, compareTextReverse: compareTextReverse, cssSafeClassName: cssSafeClassName, productGroupToButtons: productGroupToButtons, productsToButtons: productsToButtons, rebuildProductIndexes: rebuildProductIndexes, + visibleCommandButtons: visibleCommandButtons, }; })(window); diff --git a/www/kassa-confetti.js b/www/kassa-confetti.js new file mode 100644 index 0000000..8dbabb4 --- /dev/null +++ b/www/kassa-confetti.js @@ -0,0 +1,80 @@ +(function(window, document) { + var COLORS=['#ffbf69','#f05c86','#55cdb6','#7dafff','#bc8dff','#ffe17a']; + + function create() { + var canvas=null; + var context=null; + var particles=[]; + var frame=null; + var previous=null; + + function resize() { + var scale=Math.min(window.devicePixelRatio || 1,2); + canvas.width=Math.round(window.innerWidth*scale); + canvas.height=Math.round(window.innerHeight*scale); + context.setTransform(scale,0,0,scale,0,0); + } + function clear() { + if(frame !== null) window.cancelAnimationFrame(frame); + window.removeEventListener('resize',resize); + if(canvas) canvas.remove(); + canvas=null; context=null; particles=[]; frame=null; previous=null; + } + function draw(now) { + var seconds=previous === null ? 0 : Math.min(.05,(now-previous)/1000); + previous=now; + context.clearRect(0,0,window.innerWidth,window.innerHeight); + particles=particles.filter(function(particle) { + var age=(now-particle.created)/1000; + particle.x+=(particle.drift+Math.sin(age*3+particle.phase)*12)*seconds; + particle.speed+=50*seconds; + particle.y+=particle.speed*seconds; + particle.rotation+=particle.spin*seconds; + if(particle.y > window.innerHeight+20 || age > 14) return false; + context.save(); + context.translate(particle.x,particle.y); + context.rotate(particle.rotation); + context.fillStyle=particle.color; + context.globalAlpha=Math.min(1,Math.max(0,(window.innerHeight-particle.y)/80)); + var flutter=Math.max(.25,Math.abs(Math.cos(age*4+particle.phase))); + context.fillRect(-particle.size/2,-particle.size/3,particle.size,particle.size*.65*flutter); + context.restore(); + return true; + }); + if(particles.length) frame=window.requestAnimationFrame(draw); + else clear(); + } + function burst(sale) { + if(!canvas) { + canvas=document.createElement('canvas'); + canvas.className='confetti-canvas'; + canvas.setAttribute('aria-hidden','true'); + context=canvas.getContext('2d'); + if(!context) { canvas=null; return; } + resize(); + document.body.appendChild(canvas); + window.addEventListener('resize',resize); + } + var count=sale ? 90 : 18; + // Bound the work when barcode scans or commands arrive in quick succession. + particles=particles.slice(-(360-count)); + for(var i=0; i 0) { schedule(remaining); return; } + var active=document.activeElement; + var blocked=document.hidden || !document.hasFocus() || + (options.isPaused && options.isPaused()) || + (active && active.tagName === 'IFRAME') || document.querySelector('dialog[open]'); + if(blocked) { schedule(1000); return; } + var input=options.getInput(); + if(input && active !== input) input.focus({preventScroll:true}); + schedule(IDLE_MS); + } finally { + checking=false; + } + } + EVENTS.forEach(function(event) { document.addEventListener(event,activity,{capture:true,passive:true}); }); + window.addEventListener('blur',activity); + window.addEventListener('focus',check); + document.addEventListener('visibilitychange',check); + schedule(IDLE_MS); + return { + stop:function() { + stopped=true; window.clearTimeout(timer); + EVENTS.forEach(function(event) { document.removeEventListener(event,activity,true); }); + window.removeEventListener('blur',activity); + window.removeEventListener('focus',check); + document.removeEventListener('visibilitychange',check); + }, + }; + } + window.HackBankIdleFocus={watch:watch}; +})(window, document); diff --git a/www/kassa-preferences.js b/www/kassa-preferences.js new file mode 100644 index 0000000..df02a89 --- /dev/null +++ b/www/kassa-preferences.js @@ -0,0 +1,119 @@ +(function(window, document) { + var MODES={dark: 'Dark mode', banana: 'Banana mode', dank: 'Dank Hit mode', party: '🎉 Party mode', confetti: '🎊 Confetti'}; + var COMMANDS={darkmodeon: ['dark',true], darkmodeoff: ['dark',false], + bananamodeon: ['banana',true], bananamodeoff: ['banana',false], + dankmodeon: ['dank',true], dankmodeoff: ['dank',false], + confettimodeon: ['confetti',true], confettimodeoff: ['confetti',false]}; + + function create(options) { + var bananaLifetime=60*1000; + var saved={}; + try { saved=JSON.parse(window.localStorage.getItem('hackbank-appearance') || '{}') || {}; } catch(_error) { /* Storage can be disabled. */ } + var states={party: null, dark: saved.dark === true, banana: saved.banana === true || Number(saved.bananaUntil) > Date.now(), dank: saved.dank === true, confetti: saved.confetti === true}; + var switches={}; + var bananas=[]; + var confetti=window.HackBankConfetti ? window.HackBankConfetti.create() : null; + + function persist() { + try { + window.localStorage.setItem('hackbank-appearance',JSON.stringify({dark: states.dark, dank: states.dank, banana: states.banana, confetti: states.confetti, + bananas: bananas.map(function(banana) { return banana.position; })})); + } catch(_error) { /* Preferences still work without storage. */ } + } + function update() { + Object.keys(switches).forEach(function(key) { + var button=switches[key]; + button.disabled=states[key] === null; + button.setAttribute('aria-checked',String(states[key] === true)); + button.querySelector('.setting-state').textContent=states[key] === null ? 'Reading…' : (states[key] ? 'Enabled' : 'Disabled'); + }); + document.body.classList[states.dark ? 'add' : 'remove']('dark-mode'); + } + function removeBananas() { + bananas.forEach(function(banana) { + window.clearTimeout(banana.fadeTimer); + window.clearTimeout(banana.expireTimer); + banana.element.remove(); + }); + bananas=[]; + } + function addBanana(position) { + if(!states.banana) return; + position=position || {left: Math.random(), top: Math.random(), until: Date.now()+bananaLifetime}; + var remaining=Math.min(bananaLifetime,Number(position.until)-Date.now()); + if(!Number.isFinite(remaining) || remaining <= 0) return; + position={left: Math.min(1,Math.max(0,Number(position.left) || 0)), top: Math.min(1,Math.max(0,Number(position.top) || 0)), until: Date.now()+remaining}; + var element=document.createElement('img'); + element.className='dancing-banana'; + element.src='images/effects/banana/banana-peanut-butter-jelly-time.gif'; + element.alt=''; + element.setAttribute('aria-hidden','true'); + element.style.left=(12+position.left*Math.max(0,window.innerWidth-64))+'px'; + element.style.top=(12+position.top*Math.max(0,window.innerHeight-68))+'px'; + document.body.appendChild(element); + var banana={element: element, position: position}; + bananas.push(banana); + banana.fadeTimer=window.setTimeout(function() { element.classList.add('fading'); },Math.max(0,remaining-2000)); + banana.expireTimer=window.setTimeout(function() { + element.remove(); + bananas=bananas.filter(function(item) { return item !== banana; }); + persist(); + },remaining); + } + function handleCommand(command) { + var change=COMMANDS[command.trim()]; + if(!change) return false; + states[change[0]]=change[1]; + if(change[0] === 'banana' && !change[1]) removeBananas(); + if(change[0] === 'confetti' && !change[1] && confetti) confetti.clear(); + persist(); update(); + return true; + } + function toggle(key) { + var button=document.createElement('button'); + button.type='button'; + button.className='setting-toggle'; + button.id='Setting-'+key; + button.setAttribute('role','switch'); + var label=document.createElement('span'); + label.className='setting-name'; label.textContent=MODES[key]; + var state=document.createElement('span'); + state.className='setting-state'; + button.appendChild(label); button.appendChild(state); + button.addEventListener('click',function() { + var prefix=key === 'party' ? 'partymode' : key+'mode'; + options.sendCommand(prefix+(states[key] ? 'off' : 'on')); + }); + switches[key]=button; + update(); + return button; + } + document.addEventListener('click',function(event) { + if(states.banana) { addBanana(); persist(); } + if(!states.dank) return; + var marker=document.createElement('img'); + marker.className='click-hitmarker'; + marker.src='images/effects/mlg/hitmarker.gif'; + marker.alt=''; marker.setAttribute('aria-hidden','true'); + var rect=event.target.getBoundingClientRect ? event.target.getBoundingClientRect() : {left:0,top:0,width:0,height:0}; + marker.style.left=(event.detail ? event.clientX : rect.left+rect.width/2)+'px'; + marker.style.top=(event.detail ? event.clientY : rect.top+rect.height/2)+'px'; + document.body.appendChild(marker); + options.playSound('../images/effects/mlg/hitmarker.wav'); + window.setTimeout(function() { marker.remove(); },100); + }); + if(Array.isArray(saved.bananas)) saved.bananas.forEach(function(position) { if(position) addBanana(position); }); + update(); persist(); + return { + handleCommand: handleCommand, + toggle: toggle, + command: function() { if(states.confetti && confetti) confetti.burst(false); }, + sale: function() { if(states.confetti && confetti) confetti.burst(true); }, + setParty: function(state) { states.party=state.enabled === true; update(); }, + commands: Object.keys(COMMANDS).reduce(function(result, command) { + var change=COMMANDS[command]; result[command]=(change[1] ? 'Enable ' : 'Disable ')+MODES[change[0]]; return result; + },{}), + }; + } + window.HackBankPreferences={create: create}; +})(window, document); diff --git a/www/kassa-sound-settings.js b/www/kassa-sound-settings.js new file mode 100644 index 0000000..259836d --- /dev/null +++ b/www/kassa-sound-settings.js @@ -0,0 +1,132 @@ +(function(window, document) { + var FEATURES={command:'Command',deposit:'Deposit',insufficient_funds:'Insufficient funds',sale:'Sale',error:'Error'}; + var sequence=0; + + function element(tag, className, text) { + var node=document.createElement(tag); + node.className=className; + if(text) node.textContent=text; + return node; + } + function mount(container, options) { + var stopped=false; + var confirmed=null; + var pending=null; + var timeout=null; + var fields={}; + var panel=element('section','pos-sound-settings'); + panel.setAttribute('aria-label','POS sounds'); + var heading=element('div','pos-sound-settings-heading'); + var title=element('div',''); + title.appendChild(element('h2','','POS sounds')); + title.appendChild(element('p','profile-hint','Selections save and preview automatically.')); + var resetAll=element('button','sound-reset','Reset all to defaults'); + resetAll.type='button'; + resetAll.addEventListener('click',function() { send({reset:'all'}); }); + heading.appendChild(title); heading.appendChild(resetAll); + panel.appendChild(heading); + var grid=element('div','pos-sound-grid'); + var status=element('p','pos-sound-status','Reading POS sounds…'); + status.setAttribute('role','status'); + panel.appendChild(grid); panel.appendChild(status); + container.appendChild(panel); + + function render() { + Object.keys(fields).forEach(function(id) { + var field=fields[id]; + var feature=confirmed && confirmed.features.find(function(item) { return item.id === id; }); + field.select.disabled=!!pending || !feature; + field.preview.disabled=!!pending || !feature; + field.reset.disabled=!!pending || !feature || feature.selected === feature.default; + if(!feature || pending) return; + var choices=JSON.stringify(feature.choices); + if(field.choices !== choices) { + field.select.textContent=''; + feature.choices.forEach(function(sound) { + var option=element('option','',sound+(sound === feature.default ? ' (default)' : '')); + option.value=sound; + field.select.appendChild(option); + }); + field.choices=choices; + } + field.select.value=feature.selected; + field.select.title=feature.selected; + }); + resetAll.disabled=!!pending; + } + function update(state) { + if(stopped || !state || !Array.isArray(state.features)) return false; + if(!Object.keys(FEATURES).every(function(id) { + return state.features.some(function(item) { return item.id === id && Array.isArray(item.choices) && item.choices.includes(item.selected); }); + })) return false; + confirmed=state; + render(); + return true; + } + function failed(id, message) { + if(stopped || !pending || pending.id !== id) return; + window.clearTimeout(timeout); + pending=null; + render(); + status.textContent=message; + } + function send(change) { + if(stopped || pending) return; + var id=Date.now()+'-'+(++sequence)+'-'+Math.random().toString(36).slice(2,8); + pending={id:id,action:change.preview ? 'preview' : (Object.keys(change).length ? 'save' : 'read')}; + render(); + status.textContent={save:'Saving sound…',preview:'Playing preview…',read:'Reading POS sounds…'}[pending.action]; + timeout=window.setTimeout(function() { failed(id,'No reply from the bar. Your sound choice could not be confirmed.'); },8000); + window.fetch('post.php',{ + method:'POST',headers:{'Content-Type':'application/x-www-form-urlencoded'}, + body:new URLSearchParams({topic:'session/'+options.session+'/sound_settings',msg:JSON.stringify(Object.assign({request:id},change))}), + }).then(function(response) { + if(!response.ok) throw new Error('Sound request failed'); + return response.json(); + }).then(function(reply) { + if(reply.done !== 1) throw new Error('Sound request failed'); + }).catch(function() { failed(id,'Could not reach the bar. Please try again.'); }); + } + function receive(reply) { + if(stopped || !pending || !reply || reply.request !== pending.id) return; + if(!reply.ok) { failed(pending.id,reply.error || 'Sound settings could not be saved.'); return; } + var action=pending.action; + window.clearTimeout(timeout); pending=null; + if(!update(reply.settings)) { + render(); status.textContent='POS sound settings are unavailable. Please try again.'; return; + } + status.textContent={save:'Sound settings saved.',preview:'Preview requested.',read:'Sound settings are up to date.'}[action]; + } + + Object.keys(FEATURES).forEach(function(id) { + var card=element('div','pos-sound-card'); + var label=element('label','pos-sound-label',FEATURES[id]); + label.setAttribute('for','Sound-'+id); + var select=element('select','pos-sound-select'); + select.id='Sound-'+id; + select.disabled=true; + select.addEventListener('change',function() { send({feature:id,sound:select.value}); }); + var actions=element('div','pos-sound-actions'); + var preview=element('button','sound-reset','Preview'); + preview.type='button'; preview.disabled=true; + preview.setAttribute('aria-label','Preview '+FEATURES[id].toLowerCase()+' sound'); + preview.addEventListener('click',function() { send({preview:id}); }); + var reset=element('button','sound-reset','Reset'); + reset.type='button'; reset.disabled=true; + reset.setAttribute('aria-label','Reset '+FEATURES[id].toLowerCase()+' sound to default'); + reset.addEventListener('click',function() { send({reset:id}); }); + actions.appendChild(preview); actions.appendChild(reset); + card.appendChild(label); card.appendChild(select); card.appendChild(actions); + grid.appendChild(card); + fields[id]={select:select,reset:reset,preview:preview,choices:null}; + }); + update(options.initialState); + send({}); + return { + update:update, + receive:receive, + stop:function() { stopped=true; pending=null; window.clearTimeout(timeout); }, + }; + } + window.HackBankSoundSettings={mount:mount}; +})(window, document); diff --git a/www/kassa-textfill.js b/www/kassa-textfill.js index ebc420f..e583fdb 100644 --- a/www/kassa-textfill.js +++ b/www/kassa-textfill.js @@ -1,30 +1,24 @@ -var cachepixels = {}; -var cachetop = {}; - (function(window) { function textfillElement(element, options) { - var fontSize = options.maxFontPixels; + if(element.closest && element.closest('.magic-button')) return element; var parent = element.parentElement; - var maxHeight = parent ? parent.offsetHeight : 0; - var maxWidth = parent ? parent.offsetWidth : 0; - var text = element.textContent; - var textHeight; - var textWidth; - if(cachepixels[text] > 0) { - element.style.fontSize = cachepixels[text] + 'vh'; - element.style.top = cachetop[text]; - return element; + if(!parent || !parent.clientWidth || !parent.clientHeight) return element; + + // Labels can appear in different sized tiles. Measure their current + // space each time instead of caching one size for every repeated name. + element.style.fontSize = ''; + var labelStyle = window.getComputedStyle(element); + var parentStyle = window.getComputedStyle(parent); + var fontSize = Math.min(parseFloat(labelStyle.fontSize), options.maxFontPixels); + var minFontSize = options.minFontPixels || 12; + var maxWidth = element.clientWidth; + var maxHeight = parent.clientHeight - parseFloat(parentStyle.paddingTop) - parseFloat(parentStyle.paddingBottom); + + element.style.fontSize = fontSize + 'px'; + while(fontSize > minFontSize && (element.scrollWidth > maxWidth || element.scrollHeight > maxHeight)) { + fontSize = Math.max(minFontSize, fontSize - 0.5); + element.style.fontSize = fontSize + 'px'; } - do { - element.style.fontSize = fontSize + 'vh'; - textHeight = element.offsetHeight; - textWidth = element.offsetWidth; - cachepixels[text] = fontSize; - fontSize = fontSize - 0.9; - } while ((textHeight > maxHeight || textWidth > maxWidth) && fontSize > 0.5); - textHeight = element.offsetHeight; - cachetop[text] = ((maxHeight - textHeight) / 2) + 'px'; - element.style.top = cachetop[text]; return element; } diff --git a/www/post.php b/www/post.php index 5d660f2..0837606 100644 --- a/www/post.php +++ b/www/post.php @@ -21,13 +21,14 @@ function request_string($key, $max_length) { require_once(__DIR__."/config.php"); $topic = request_string("topic", 128); -if (!isset($_REQUEST["msg"]) || !is_string($_REQUEST["msg"]) || strlen($_REQUEST["msg"]) > 512) { +if (!preg_match('/^session\/[A-Za-z0-9_-]{1,64}\/(input|sound_settings|button_studio)$/', $topic)) { + fail_request("Invalid topic"); +} +$max_length = str_ends_with($topic, '/button_studio') ? 160 * 1024 : 512; +if (!isset($_REQUEST["msg"]) || !is_string($_REQUEST["msg"]) || strlen($_REQUEST["msg"]) > $max_length) { fail_request("Invalid msg"); } $msg = $_REQUEST["msg"]; -if (!preg_match('/^session\/[A-Za-z0-9_-]{1,64}\/input$/', $topic)) { - fail_request("Invalid topic"); -} require("phpMQTT.php"); use Bluerhinos\phpMQTT; diff --git a/www/shaders/README.md b/www/shaders/README.md new file mode 100644 index 0000000..600225b --- /dev/null +++ b/www/shaders/README.md @@ -0,0 +1,34 @@ +# Button shader sources + +These five adaptations use GLSL supplied by the user for the linked examples. +Each JavaScript file registers a local fragment-shader body; no remote shader +code, images, audio, libraries or buffer passes are fetched at runtime. + +| Preset | Source | Credit and licence supplied with the code | +| --- | --- | --- | +| Wonderful | [mtyGWy](https://www.shadertoy.com/view/mtyGWy), [tutorial](https://youtu.be/f4s1h2YETNY) | Tutorial credit preserved; no licence statement in the supplied source. | +| Heartfelt | [ltffzl](https://www.shadertoy.com/view/ltffzl) | Martijn Steinrucken / BigWings, 2017. CC BY-NC-SA 3.0 Unported. | +| Star Nest | [XlfGRj](https://www.shadertoy.com/view/XlfGRj) | Pablo Roman Andrioli. MIT. | +| 2D Clouds | [4tdSWr](https://www.shadertoy.com/view/4tdSWr) | No author or licence statement in the supplied source. | +| Universe Within | [lscczl](https://www.shadertoy.com/view/lscczl) | Martijn Steinrucken / BigWings, 2018. CC BY-NC-SA 3.0 Unported. | + +The shader-specific notices are retained in their respective files; the MIT +notice is included in full. See the [CC BY-NC-SA 3.0 terms](https://creativecommons.org/licenses/by-nc-sa/3.0/) +for the two BigWings shaders. These notices describe the supplied shader code; +they do not assert a new licence for the rest of the application. + +All scenes use the shared renderer's username-seeded clock, three scene colours, +speed and intensity controls. Numerical guards prevent zero-distance divisions; +descending smoothstep transitions use an explicit interpolation for WebGL 1. +The original Wonderful ring iterations, Star Nest's 20 × 17 volume/fractal +iterations and 2D Clouds' noise layers are retained. + +Wonderful uses a 2× closer view so its pattern is legible on a small button. +Heartfelt uses a procedural bokeh backdrop in place of its missing image input, +with closer framing and continuous rain covering the whole button. Its heart +accent changes the fog instead of masking the rain to the centre. Lightning +is subdued and full-button intro/outro fades are removed. +Universe Within keeps its four network +layers and sparkles, replaces audio FFT input with a quiet glow, fits the +vignette to the button and removes long intro/outro blackouts. Both are silent +and use fixed ambient camera/parallax settings instead of mouse input. diff --git a/www/shaders/clouds.js b/www/shaders/clouds.js new file mode 100644 index 0000000..65eb279 --- /dev/null +++ b/www/shaders/clouds.js @@ -0,0 +1,60 @@ +/* 2D Clouds - adapted from the user-supplied shader. + Source: https://www.shadertoy.com/view/4tdSWr + The supplied source did not include author or licence comments. + Original noise layers retained; sky/cloud colours use the scene palette. */ +(function(window) { + var shaders=window.HackBankExtraButtonShaders=window.HackBankExtraButtonShaders || {}; + shaders['shader-clouds']=` + const mat2 cloudMatrix=mat2(1.6,1.2,-1.2,1.6); + vec2 cloudHash(vec2 p) { + p=vec2(dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3))); + return -1.+2.*fract(sin(p)*43758.5453123); + } + float cloudNoise(vec2 p) { + const float K1=.366025404, K2=.211324865; + vec2 i=floor(p+(p.x+p.y)*K1); + vec2 a=p-i+(i.x+i.y)*K2; + vec2 o=a.x>a.y ? vec2(1.,0.) : vec2(0.,1.); + vec2 b=a-o+K2, c=a-1.+2.*K2; + vec3 h=max(.5-vec3(dot(a,a),dot(b,b),dot(c,c)),0.); + vec3 n=h*h*h*h*vec3(dot(a,cloudHash(i)),dot(b,cloudHash(i+o)),dot(c,cloudHash(i+1.))); + return dot(n,vec3(70.)); + } + float cloudFbm(vec2 p) { + float sum=0., amplitude=.1; + for(int i=0;i<7;i++) { + sum+=cloudNoise(p)*amplitude;p=cloudMatrix*p;amplitude*=.4; + } + return sum; + } + vec3 scene() { + vec2 p=uv, q=p*vec2(resolution.x/resolution.y,1.); + float drift=time*.03, warp=cloudFbm(q*1.1*.5); + float ridges=0., weight=.8; + q*=1.1;q-=warp-drift; + for(int i=0;i<8;i++) { + ridges+=abs(weight*cloudNoise(q));q=cloudMatrix*q+drift;weight*=.7; + } + float shape=0.;q=p*vec2(resolution.x/resolution.y,1.)*1.1;q-=warp-drift;weight=.7; + for(int i=0;i<8;i++) { + shape+=weight*cloudNoise(q);q=cloudMatrix*q+drift;weight*=.6; + } + shape*=ridges+shape; + float colour=0.;drift=time*.03*2.; + q=p*vec2(resolution.x/resolution.y,1.)*1.1*2.;q-=warp-drift;weight=.4; + for(int i=0;i<7;i++) { + colour+=weight*cloudNoise(q);q=cloudMatrix*q+drift;weight*=.6; + } + float detail=0.;drift=time*.03*3.; + q=p*vec2(resolution.x/resolution.y,1.)*1.1*3.;q-=warp-drift;weight=.4; + for(int i=0;i<7;i++) { + detail+=abs(weight*cloudNoise(q));q=cloudMatrix*q+drift;weight*=.6; + } + colour+=detail; + vec3 sky=mix(color1,color0,p.y); + vec3 clouds=color2*vec3(1.1,1.1,1.)*clamp(.5+.3*colour,0.,1.); + shape=.2+8.*shape*ridges; + return mix(sky,clamp(.5*sky+clouds,0.,1.),clamp(shape+colour,0.,1.)); + } + `; +})(window); diff --git a/www/shaders/heartfelt.js b/www/shaders/heartfelt.js new file mode 100644 index 0000000..c35b686 --- /dev/null +++ b/www/shaders/heartfelt.js @@ -0,0 +1,102 @@ +/* Heartfelt - Martijn Steinrucken aka BigWings - 2017. + Twitter: @The_ArtOfCode + Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. + https://creativecommons.org/licenses/by-nc-sa/3.0/ + Source: https://www.shadertoy.com/view/ltffzl + Video: https://www.youtube.com/watch?v=uiF5Tlw22PI + Adapted from the user-supplied source for silent ambient buttons: + procedural palette backdrop replaces iChannel0; no audio or mouse input; + subdued lightning, closer framing and continuous rain across the button. + The heart accents the fog rather than limiting where rain appears. + N13 noise is credited to Dave Hoskins in the supplied source. */ +(function(window) { + var shaders=window.HackBankExtraButtonShaders=window.HackBankExtraButtonShaders || {}; + shaders['shader-heartfelt']=` + float rainS(float a,float b,float x) { + if(abs(b-a)<.000001) return 0.; + float t=clamp((x-a)/(b-a),0.,1.); + return t*t*(3.-2.*t); + } + vec3 rainN13(float p) { + vec3 p3=fract(vec3(p)*vec3(.1031,.11369,.13787)); + p3+=dot(p3,p3.yzx+19.19); + return fract(vec3((p3.x+p3.y)*p3.z,(p3.x+p3.z)*p3.y,(p3.y+p3.z)*p3.x)); + } + float rainN(float t) { return fract(sin(t*12345.564)*7658.76); } + float rainSaw(float b,float t) { return rainS(0.,b,t)*rainS(1.,b,t); } + vec2 rainLayer(vec2 p,float t) { + vec2 original=p, a=vec2(6.,1.), grid=a*2.; + p.y+=t*.75; + vec2 id=floor(p*grid); + p.y+=rainN(id.x); + id=floor(p*grid); + vec3 n=rainN13(id.x*35.2+id.y*2376.1); + vec2 st=fract(p*grid)-vec2(.5,0.); + float x=n.x-.5, y=original.y*20.; + x+=sin(y+sin(y))*(.5-abs(x))*(n.z-.5); + x*=.7; + y=(rainSaw(.85,fract(t+n.z))-.5)*.9+.5; + float d=length((st-vec2(x,y))*a.yx); + float mainDrop=rainS(.4,0.,d); + float r=sqrt(max(0.,rainS(1.,y,st.y))); + float cd=abs(st.x-x); + float trail=rainS(.23*r,.15*r*r,cd); + float front=rainS(-.02,.02,st.y-y); + trail*=front*r*r; + y=fract(original.y*10.)+(st.y-.5); + float droplets=rainS(.3,0.,length(st-vec2(x,y))); + return vec2(mainDrop+droplets*r*front,trail); + } + float rainStatic(vec2 p,float t) { + p*=40.; + vec2 id=floor(p);p=fract(p)-.5; + vec3 n=rainN13(id.x*107.45+id.y*3543.654); + float d=length(p-(n.xy-.5)*.7); + return rainS(.3,0.,d)*fract(n.z*10.)*rainSaw(.025,fract(t+n.z)); + } + vec2 rainDrops(vec2 p,float t,float l0,float l1,float l2) { + float s=rainStatic(p,t)*l0; + vec2 a=rainLayer(p,t)*l1, b=rainLayer(p*1.85,t)*l2; + return vec2(rainS(.3,1.,s+a.x+b.x),max(a.y*l0,b.y*l1)); + } + vec3 rainBackdrop(vec2 p,float blur) { + vec2 q=(p-.5)*vec2(resolution.x/resolution.y,1.); + vec3 col=mix(color0,color1,clamp(p.y,0.,1.))*.35; + for(int i=0;i<6;i++) { + float n=float(i); + vec2 center=vec2(sin(n*2.3+time*.015)*resolution.x/resolution.y*.4,cos(n*1.7+time*.012)*.4); + vec2 delta=q-center; + float radius=.025+blur*.018; + col+=palette(n*.2)*exp(-dot(delta,delta)/radius)*.7; + } + return col; + } + vec3 scene() { + vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.), imageUV=uv; + float T=mod(time,102.), story=rainS(0.,70.,T); + float t=time*.2; + float zoom=.5;p*=zoom; + float minBlur=4.+rainS(.5,1.,story)*3.; + float maxBlur=6.+rainS(.5,1.,story)*1.5; + vec2 heartUV=p-vec2(0.,-.1);heartUV.x*=.5; + float shrink=rainS(110.,70.,T); + heartUV.y-=sqrt(abs(heartUV.x))*.5*shrink; + float heart=rainS(.4*shrink,.2*shrink,length(heartUV))*shrink; + maxBlur-=heart;p*=1.5;t*=.25; + imageUV=(imageUV-.5)*(.9+zoom*.1)+.5; + float rainAmount=.65+.35*heart; + float l0=rainS(-.5,1.,rainAmount)*2., l1=rainS(.25,.75,rainAmount), l2=rainS(0.,.5,rainAmount); + vec2 drops=rainDrops(p,t,l0,l1,l2), e=vec2(.001,0.); + vec2 normal=vec2(rainDrops(p+e,t,l0,l1,l2).x,rainDrops(p+e.yx,t,l0,l1,l2).x)-drops.x; + float focus=mix(maxBlur-drops.y,minBlur,rainS(.1,.2,drops.x)); + vec3 col=rainBackdrop(imageUV+normal,focus); + float shift=sin((T+3.)*.1)*.5+.5+story; + col*=mix(vec3(1.),vec3(.8,.9,1.3),shift); + float flash=sin((T+3.)*.5*sin((T+3.)*5.)); + flash*=pow(max(0.,sin((T+3.)*.5+sin((T+3.)*.5))),10.); + col*=1.+flash*.08*(1.-story*.9); + vec2 vignette=imageUV-.5;col*=max(.15,1.-dot(vignette,vignette)); + return mix(pow(max(col,vec3(0.)),vec3(1.2)),col,heart); + } + `; +})(window); diff --git a/www/shaders/star-nest.js b/www/shaders/star-nest.js new file mode 100644 index 0000000..d75d241 --- /dev/null +++ b/www/shaders/star-nest.js @@ -0,0 +1,58 @@ +/* Star Nest by Pablo Roman Andrioli. Source supplied by the user. + https://www.shadertoy.com/view/XlfGRj + + MIT License + Copyright (c) Pablo Roman Andrioli + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + Adapted to fixed camera angles, scene colours and the shared seeded clock. */ +(function(window) { + var shaders=window.HackBankExtraButtonShaders=window.HackBankExtraButtonShaders || {}; + shaders['shader-star-nest']=` + vec3 scene() { + vec2 p=uv-.5;p.y*=resolution.y/resolution.x; + vec3 direction=vec3(p*.8,1.); + float drift=time*.01+.25; + mat2 rot1=mat2(cos(.5),sin(.5),-sin(.5),cos(.5)); + mat2 rot2=mat2(cos(.8),sin(.8),-sin(.8),cos(.8)); + direction.xz*=rot1;direction.xy*=rot2; + vec3 origin=vec3(1.,.5,.5)+vec3(drift*2.,drift,-2.); + origin.xz*=rot1;origin.xy*=rot2; + float s=.1, fade=1.;vec3 v=vec3(0.); + for(int r=0;r<20;r++) { + vec3 point=origin+s*direction*.5; + point=abs(vec3(.85)-mod(point,vec3(1.7))); + float previous=0., change=0.; + for(int i=0;i<17;i++) { + point=abs(point)/max(dot(point,point),.000001)-.53; + float size=length(point);change+=abs(size-previous);previous=size; + } + float darkMatter=max(0.,.3-change*change*.001); + change*=change*change; + if(r>6)fade*=1.-darkMatter; + v+=fade; + v+=vec3(s,s*s,s*s*s*s)*change*.0015*fade; + fade*=.73;s+=.1; + } + v=mix(vec3(length(v)),v,.85)*.01; + return color0*v.r+color1*v.g+color2*v.b; + } + `; +})(window); diff --git a/www/shaders/universe-within.js b/www/shaders/universe-within.js new file mode 100644 index 0000000..69071ce --- /dev/null +++ b/www/shaders/universe-within.js @@ -0,0 +1,73 @@ +/* The Universe Within - Martijn Steinrucken aka BigWings - 2018. + Twitter: @The_ArtOfCode + Creative Commons Attribution-NonCommercial-ShareAlike 3.0 Unported. + https://creativecommons.org/licenses/by-nc-sa/3.0/ + Source: https://www.shadertoy.com/view/lscczl + Video: https://youtu.be/GAhu4ngQa48 + Tutorial: https://youtu.be/3CycKKJiwis + Adapted from the user-supplied source for silent ambient buttons: + audio FFT replaced with a gentle constant glow; scene palette, fixed parallax, + button-shaped vignette, no intro/outro blackout, WebGL 1 array indexing. */ +(function(window) { + var shaders=window.HackBankExtraButtonShaders=window.HackBankExtraButtonShaders || {}; + shaders['shader-universe']=` + float netS(float a,float b,float x) { + float t=clamp((x-a)/(b-a),0.,1.); + return t*t*(3.-2.*t); + } + float netN21(vec2 p) { + vec3 a=fract(vec3(p.xyx)*vec3(213.897,653.453,253.098)); + a+=dot(a,a.yzx+79.76); + return fract((a.x+a.y)*a.z); + } + vec2 netPosition(vec2 id,vec2 offset,float t) { + float n=netN21(id+offset), a=t+n; + return offset+vec2(sin(a*fract(n*10.)),cos(a*fract(n*100.)))*.4; + } + float netLine(vec2 a,vec2 b,vec2 p) { + vec2 pa=p-a, ba=b-a; + float h=clamp(dot(pa,ba)/max(dot(ba,ba),.000001),0.,1.); + float d=length(pa-ba*h), span=length(a-b); + float fade=netS(1.5,.5,span)+netS(.05,.02,abs(span-.75)); + return netS(.04,.01,d)*fade; + } + float netLayer(vec2 st,float n,float t) { + vec2 id=floor(st)+n;st=fract(st)-.5; + vec2 points[9]; + for(int i=0;i<9;i++) { + vec2 offset=vec2(mod(float(i),3.)-1.,floor(float(i)/3.)-1.); + points[i]=netPosition(id,offset,t); + } + float lines=0., sparkle=0.; + for(int i=0;i<9;i++) { + lines+=netLine(points[4],points[i],st); + float d=length(st-points[i]); + float star=.005/max(d*d,.000001)*netS(1.,.7,d); + float pulse=sin((fract(points[i].x)+fract(points[i].y)+t)*5.)*.4+.6; + sparkle+=star*pow(pulse,20.); + } + lines+=netLine(points[1],points[3],st)+netLine(points[1],points[5],st); + lines+=netLine(points[7],points[5],st)+netLine(points[7],points[3],st); + float phase=(sin(t+n)+sin(t*.1))*.25+.5; + phase+=pow(sin(t*.1)*.5+.5,50.)*5.; + return lines+sparkle*phase; + } + vec3 scene() { + vec2 p=(uv-.5)*vec2(resolution.x/resolution.y,1.); + float t=time*.1, s=sin(t), c=cos(t); + mat2 rotation=mat2(c,-s,s,c); + vec2 st=p*rotation; + float network=0.; + for(int layer=0;layer<4;layer++) { + float i=float(layer)*.25, z=fract(t+i); + float size=mix(15.,1.,z), fade=netS(0.,.6,z)*netS(1.,.8,z); + network+=fade*netLayer(st*size,i,time); + } + vec3 wave=vec3(s,cos(t*.4),-sin(t*.24))*.4+.6; + vec3 base=(color0*wave.r+color1*wave.g+color2*wave.b)/1.5; + vec3 col=base*(network+max(0.,-p.y)*.15); + vec2 vignette=uv-.5; + return col*max(.15,1.-dot(vignette,vignette)); + } + `; +})(window); diff --git a/www/shaders/wonderful.js b/www/shaders/wonderful.js new file mode 100644 index 0000000..f7cdbe9 --- /dev/null +++ b/www/shaders/wonderful.js @@ -0,0 +1,28 @@ +/* Adapted from the user-supplied Wonderful tutorial shader. + Shader: https://www.shadertoy.com/view/mtyGWy + Tutorial: https://youtu.be/f4s1h2YETNY + Palette reference: https://iquilezles.org/articles/palettes/ + The supplied source did not include a licence statement. */ +(function(window) { + var shaders=window.HackBankExtraButtonShaders=window.HackBankExtraButtonShaders || {}; + shaders['shader-wonderful']=` + vec3 wonderfulPalette(float t) { + vec3 wave=.5+.5*cos(6.28318*(vec3(t)+vec3(.263,.416,.557))); + return (color0*wave.r+color1*wave.g+color2*wave.b)/max(.001,wave.r+wave.g+wave.b); + } + vec3 scene() { + vec2 p=(uv*2.-1.)*vec2(resolution.x/resolution.y,1.)*.5; + vec2 original=p; + vec3 result=vec3(0.); + for(int i=0;i<4;i++) { + p=fract(p*1.5)-.5; + float d=length(p)*exp(-length(original)); + vec3 col=wonderfulPalette(length(original)+float(i)*.4+time*.4); + d=abs(sin(d*8.+time)/8.); + d=pow(.01/max(d,.0001),1.2); + result+=col*d; + } + return result; + } + `; +})(window); diff --git a/www/sounds/KDE_Beep_Beep.wav b/www/sounds/POS/command/KDE_Beep_Beep.wav similarity index 100% rename from www/sounds/KDE_Beep_Beep.wav rename to www/sounds/POS/command/KDE_Beep_Beep.wav diff --git a/www/sounds/KDE_Beep_ClassicBeep.wav b/www/sounds/POS/command/KDE_Beep_ClassicBeep.wav similarity index 100% rename from www/sounds/KDE_Beep_ClassicBeep.wav rename to www/sounds/POS/command/KDE_Beep_ClassicBeep.wav diff --git a/www/sounds/POS/deposit/breathtaking.wav b/www/sounds/POS/deposit/breathtaking.wav new file mode 100644 index 0000000..3d98414 Binary files /dev/null and b/www/sounds/POS/deposit/breathtaking.wav differ diff --git a/www/sounds/itsgone.wav b/www/sounds/POS/deposit/itsgone.wav similarity index 100% rename from www/sounds/itsgone.wav rename to www/sounds/POS/deposit/itsgone.wav diff --git a/www/sounds/POS/deposit/ivodanku.wav b/www/sounds/POS/deposit/ivodanku.wav new file mode 100644 index 0000000..e7f73c6 Binary files /dev/null and b/www/sounds/POS/deposit/ivodanku.wav differ diff --git a/www/sounds/POS/deposit/money.wav b/www/sounds/POS/deposit/money.wav new file mode 100644 index 0000000..503d7ea Binary files /dev/null and b/www/sounds/POS/deposit/money.wav differ diff --git a/www/sounds/KDE_Beep_Beep.wav.org b/www/sounds/POS/error/KDE_Beep_Beep.wav similarity index 50% rename from www/sounds/KDE_Beep_Beep.wav.org rename to www/sounds/POS/error/KDE_Beep_Beep.wav index 3c18175..238e384 100644 Binary files a/www/sounds/KDE_Beep_Beep.wav.org and b/www/sounds/POS/error/KDE_Beep_Beep.wav differ diff --git a/www/sounds/POS/insufficient_funds/insufficient_funds.wav b/www/sounds/POS/insufficient_funds/insufficient_funds.wav new file mode 100644 index 0000000..d86e460 Binary files /dev/null and b/www/sounds/POS/insufficient_funds/insufficient_funds.wav differ diff --git a/www/sounds/sinterklaas.wav b/www/sounds/POS/insufficient_funds/sinterklaas.wav similarity index 100% rename from www/sounds/sinterklaas.wav rename to www/sounds/POS/insufficient_funds/sinterklaas.wav diff --git a/www/sounds/POS/sale/cash_tycoon.wav b/www/sounds/POS/sale/cash_tycoon.wav new file mode 100644 index 0000000..580094b Binary files /dev/null and b/www/sounds/POS/sale/cash_tycoon.wav differ diff --git a/www/sounds/POS/sale/katsjing.wav b/www/sounds/POS/sale/katsjing.wav new file mode 100644 index 0000000..c373162 Binary files /dev/null and b/www/sounds/POS/sale/katsjing.wav differ diff --git a/www/sounds/katsjing.wav b/www/sounds/katsjing.wav deleted file mode 100644 index 60ad0f7..0000000 Binary files a/www/sounds/katsjing.wav and /dev/null differ diff --git a/www/sounds/ns.wav b/www/sounds/soundboard/ns.wav similarity index 100% rename from www/sounds/ns.wav rename to www/sounds/soundboard/ns.wav diff --git a/www/sounds/soundboard/throw.wav b/www/sounds/soundboard/throw.wav new file mode 100644 index 0000000..e8052f8 Binary files /dev/null and b/www/sounds/soundboard/throw.wav differ diff --git a/www/sounds/vergaderen.mp3 b/www/sounds/soundboard/vergaderen.mp3 similarity index 100% rename from www/sounds/vergaderen.mp3 rename to www/sounds/soundboard/vergaderen.mp3 diff --git a/www/sounds/soundboard/vergaderen_extreme.wav b/www/sounds/soundboard/vergaderen_extreme.wav new file mode 100644 index 0000000..ea1cec2 Binary files /dev/null and b/www/sounds/soundboard/vergaderen_extreme.wav differ diff --git a/www/sounds/soundboard/yeet.wav b/www/sounds/soundboard/yeet.wav new file mode 100644 index 0000000..77a1171 Binary files /dev/null and b/www/sounds/soundboard/yeet.wav differ diff --git a/www/stom.css b/www/stom.css index d1617a7..1eae003 100644 --- a/www/stom.css +++ b/www/stom.css @@ -1,507 +1,583 @@ +/* Self-contained Bootstrap-style container / row / column grid. + No CDN, font service, framework JavaScript, or external assets are required. */ +:root { + color-scheme: light; + --ink: #1c2937; + --muted: #65727e; + --shell: #121b28; + --panel: #f1f4f5; + --line: #dce3e6; + --accent: #ffbf69; + --receipt-width: 336px; + --gap: 16px; + --button-columns: 5; + --button-rows: 5; + --buttons-per-page: 25; + --account-columns: 6; + --account-rows: 7; + --account-row-height: 84px; + --accounts-per-page: 42; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + font-synthesis: none; +} + +*, *::before, *::after { box-sizing: border-box; } +html { height: 100%; } body { - background-color: #f8f8f8; + height: 100vh; + height: 100dvh; + margin: 0; + padding: 0 20px 18px; + display: flex; + flex-direction: column; + gap: var(--gap); overflow: hidden; -} + background: radial-gradient(ellipse at top left, #253346 0, transparent 55%), var(--shell); + color: var(--ink); + font-size: 16px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; +} +.container-fluid { width: 100%; margin-right: auto; margin-left: auto; } +.row { display: flex; flex-wrap: wrap; } +.row > * { min-width: 0; } +.col { flex: 1 0 0%; } +.col-auto { flex: 0 0 auto; width: auto; } +h1, h2, p { margin: 0; } +iframe { display: block; border: 0; } +::selection { color: #121b28; background: var(--accent); } +* { scrollbar-width: thin; scrollbar-color: #aebbc3 transparent; } -#Firstscreen { - background-color: #f8f8f8; -/* width: 1024px; - height: 768px; */ - height: 99vh; - width: 99vw; - position: relative; -} -#Invoer { - background-color: #aaaaaa; - width: 99vw; - height: 5vh; - position: absolute; - left: 0vw; - bottom: 5vh; -} -#Log { - width: 99%; - height: 29vh; - position: absolute; - left: 5px; - bottom: 70px; - top: 64vh; - background-color: #ffffff; - overflow: scroll; - font-size: 3vh; -} +/* First row: identity and the original register sections. */ +.app-header { + flex: 0 0 82px; + align-items: center; + flex-wrap: nowrap; + gap: 26px; + border-bottom: 1px solid #ffffff12; +} +.brand { display: flex; align-items: center; gap: 10px; } +.brand-abort { display: flex; flex: 0 0 auto; min-height: 60px; padding: 0; border: 0; border-radius: 6px; background: transparent; cursor: pointer; font: inherit; text-align: left; } +.brand-abort:hover { box-shadow: 0 0 0 3px #ffbf6980; } +.brand-abort:focus-visible { outline: 3px solid var(--accent); outline-offset: 3px; } +.brand-logo { display: block; flex: 0 0 auto; width: 84px; height: 56px; padding: 4px; border-radius: 6px; background: #fff; object-fit: contain; } +.brand-name { display: block; color: #f8fafc; font-size: 24px; font-weight: 750; letter-spacing: -1px; } +.brand-caption { display: block; margin-top: 1px; color: #a1adbc; font-size: 9px; font-weight: 600; letter-spacing: 1.3px; } +#LeftButtons { display: flex; align-items: center; gap: 6px; } -#Zoek { - -moz-box-sizing: border-box; - -moz-user-input: enable; - -moz-user-select: text; - background-color: #F8F8F8; - border: 1px solid rgba(,0,0,0,0.1); - border-radius: 3px; - color: #222222; - display: block; - font-size: 4vh; - font-weight: normal; - letter-spacing: -1px; - line-height: 4vh; - margin: 0; - outline: medium none; - padding: 0.5vh; +/* Keep all command handlers on their existing .Knopje elements. */ +.Knopje { position: relative; - width: 100%; - z-index: 400; -} + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + padding: 12px; + border: 1px solid var(--line); + border-radius: 9px; + background: #fff; + color: var(--ink); + cursor: pointer; + font-size: 17px; + font-weight: 650; + line-height: 1.2; + text-align: center; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: background-color 140ms, border-color 140ms, box-shadow 140ms, transform 140ms; +} +.Knopje:hover { border-color: #97a9b7; background-color: #f9fcff; box-shadow: 0 4px 12px #172d4010; transform: translateY(-2px); } +.Knopje:active { transform: translateY(1px) scale(.98); box-shadow: none; } +.Knopje:focus-visible { outline: 3px solid #db860d; outline-offset: 3px; z-index: 1; } +.Knopjetext, .Buttontext, .Paginatext { display: block; min-width: 0; max-width: 100%; overflow-wrap: anywhere; } +.topknop { + flex: 1 1 0; + min-height: 60px; + padding: 12px 8px; + border-color: transparent; + background: transparent; + color: #b9c4d2; + font-size: 14px; + font-weight: 600; + gap: 7px; +} +.topknop:hover { color: #fff; background: #ffffff0c; border-color: #ffffff18; box-shadow: none; } +.topknop.activetop { color: #1e2730; background: var(--accent); box-shadow: 0 3px 14px #ffbf6912; } +.topknop.irc.activetop { color: #211307; background: #ff6600; box-shadow: none; } +.topknop.spacecon.activetop { color: #082c3a; background: #18bcf2; box-shadow: none; } +.topknop.abort { flex: .8 1 0; color: #f6a1a6; border-color: #dc69702b; background: #7b263226; } +.topknop.abort:hover { color: #fff; border-color: #e17980; background: #8b3542; } +.topknop.spacecon { flex-grow: 1.35; } +.nav-icon { display: flex; flex: 0 0 17px; order: -1; } +.nav-icon svg { width: 17px; height: 17px; } -#Receipt { - width: 48vw; - height: 45vh; - position: absolute; - left: 5px; - top: 10px; - color: #000000; - background-color: #ffffff; - font-weight: normal; - font-size: 3vh; - line-height: 4vh; - margin: 0; - outline: medium none; - padding: 0; - overflow:scroll; - overflow-x: hidden; -} -#Receipt2 { - width: 30vw; - height: 64vh; - position: absolute; - left: 66%; - top: 26vh; - color: #000000; - background-color: #ffffff; - font-size: 3vh; - font-weight: normal; - line-height: 4vh; - margin: 0; - outline: medium none; - padding: 0; - overflow:scroll; - overflow-x: hidden; - border-left: solid 1px; -} -.Product { - width: 15.5vw; - position: absolute; - overflow: hidden; - left: 0px; - height: 4vh; -} -.Times { - width: 3vw; - position: absolute; - overflow: hidden; - left: 15.5vw; - text-align: right; -} -.LoseOrGain { - width: 2.5vw; - position: absolute; - left: 18.5vw; - overflow: hidden; - text-align: left; - font-size: 2vh; -} -.Itemeuro:after { - left: 21.5vw; - position: absolute; - content: "€"; -} -.ItemAmount { - outline: medium none; - width: 7vw; - position: relative; - left: 21.5vw; - text-align: right; - overflow: hidden; -} +/* The flexible left column and a fixed-width paper receipt. */ +.workspace { flex: 1 1 0; min-height: 0; flex-wrap: nowrap; gap: var(--gap); } +.workspace-main { display: flex; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid #ffffff14; border-radius: 12px; background: var(--panel); } +.screens { position: relative; flex: 1 1 0; min-height: 0; } +.overview-screen, .selection-screen { position: absolute; inset: 0; display: flex; min-height: 0; flex-direction: column; background: var(--panel); } +.overview-screen { padding: 20px; gap: 16px; } +.section-title { font-size: 20px; letter-spacing: -.5px; } +#Receipt { flex: 1; min-height: 0; overflow: auto; } +#Buttons { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; } +#Buttons .Knopje { min-height: 60px; font-size: 15px; } +/* Second row: backend prompts, paging, cash, and contextual actions. */ +#TopButtons { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 8px; + min-height: 88px; + max-height: 30%; + padding: 12px 16px; + overflow: auto; + border-bottom: 1px solid var(--line); + background: #fcfdfd; +} +#TopButtons .Knopje { flex: 0 0 auto; min-height: 56px; max-width: 180px; padding: 10px 16px; font-size: 14px; } +#TopButtons .page { min-width: 110px; color: #526273; background: #edf2f5; border-color: #e0e7eb; } +#TopButtons .page.currentpage { color: #263e50; background: #e0e9ef; border-color: #a1b6c5; } +.Paginatext { line-height: 1.1; } +.Paginatext br { display: block; } +.Question { flex: 1; color: #536371; font-size: 15px; line-height: 1.45; overflow-wrap: anywhere; } +.Question::before { content: "›"; display: inline-block; margin-right: 10px; color: #c87a19; font-size: 21px; font-weight: 700; } +#TopButtons .cash { color: #2f5b4d; background: #e7f1eb; border-color: #cfe3d5; } +.cash::before { content: "€"; margin-right: 7px; font-size: 16px; } -#Totals { - width: 48vw; - height: 45vh; - position: absolute; - top: 10px; - right: 5px; - color: #000000; - background-color: #ffffff; - font-weight: normal; - font-size: 4vh; - line-height: 5vh; - margin: 0; - outline: medium none; - padding: 0; - overflow:scroll; +#MainButtons { + flex: 1 1 0; + min-height: 0; + padding: 16px; overflow-x: hidden; -} -.UserName { - width: 50%; - position: absolute; - overflow: hidden; - left: 0px; -} -.GainOrLose { - width: 20%; - position: absolute; - left: 50%; - overflow: hidden; - text-align: left; -} -.Totaleuro:after { - left: 70%; - position: absolute; - content: "€"; -} -.Totalamount { - outline: medium none; - width: 25%; - position: relative; - left: 85%; - text-align: right; - overflow: hidden; -} -.infoboxes .UserName { - width: 30%; - position: absolute; - overflow: hidden; - left: 0px; -} -.infoboxes .GainOrLose { - width: 10%; - position: absolute; - left: 30%; - overflow: hidden; - text-align: left; -} -.infoboxes .Totaleuro:after { - left: 46%; - position: absolute; - content: "€"; -} -.infoboxes { - font-size: 25px; -} -.infoboxes .Totalamount { - outline: medium none; - width: 25%; - position: absolute; - left: 33%; - text-align: right; - overflow: hidden; -} -.NowHas { - position: absolute; - left: 60%; - width: 15%; -} -.NowHas:after { + overflow-y: auto; + touch-action: pan-y; + overscroll-behavior-y: contain; + background-image: radial-gradient(#a9bbc326 .7px, transparent .7px); + background-size: 12px 12px; +} +.Pagina { + display: grid; + height: 100%; + grid-template-columns: repeat(var(--button-columns), minmax(0, 1fr)); + grid-template-rows: repeat(var(--button-rows), minmax(72px, 1fr)); + gap: 10px; +} +.Knop { min-height: 72px; padding: 12px; font-size: 18px; box-shadow: 0 2px 2px #20384704; } +.Knop:has(.extra) { justify-content: flex-start; padding-top: 10px; padding-bottom: 30px; text-align: left; } +.Knop .Buttontext { display: -webkit-box; width: 100%; max-height: 2.4em; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } +.account { background-position: center; background-repeat: no-repeat; background-size: cover; } +.account .Buttontext { text-shadow: none; } +.productgroups { justify-content: flex-start; text-align: left; border-color: #e7dbca; background: #fffaf1; } +.productgroups::after { content: "↗"; position: absolute; right: 10px; bottom: 7px; color: #ad7a35; font-size: 16px; } +.extra { position: absolute; - left: 150%; - width: 15%; - content: "€"; -} -.NewSaldo { - position: relative; - left: 90%; - text-align: right; - width: 15%; -} - + bottom: 8px; + max-width: calc(100% - 16px); + padding: 3px 7px; + border-radius: 4px; + background: #e8f2ed; + color: #2f6753; + font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; + font-size: 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; + line-height: 1.2; + overflow-wrap: anywhere; +} +.extra.right { right: 8px; } +.extra.left { left: 8px; } +.extra.rood { color: #a33342; background: #fce6e9; } +.extra.orange { color: #8b5512; background: #fff0d9; } +.extra.fullwidthButton { left: 8px; width: auto; text-align: right; } +/* Keep empty cells when filtering; account pages fit the available screen. */ +#AccountList { height: 100%; } +.account-list { grid-template-columns: repeat(var(--account-columns), minmax(0, 1fr)); grid-template-rows: repeat(var(--account-rows), var(--account-row-height)); align-content: start; gap: 8px; } +.account-list .Knop { justify-content: space-between; gap: 10px; min-height: var(--account-row-height); padding: 8px 12px; font-size: 17px; text-align: left; overflow: hidden; } +.account-list .Knop .Buttontext { flex: 1 1 auto; width: auto; min-width: 0; } +.account-list .Knop .extra { position: static; flex: 0 0 auto; max-width: 45%; width: auto; margin-left: auto; padding: 4px 8px; font-size: 14px; } -#Buttons { - width: 99%; - height: 13vh; - position: absolute; - left: 0px; - top: 46vh; - overflow:hidden; +@font-face { + font-family: "HackBank Cyber"; + src: url("fonts/OCRA.otf") format("opentype"); + font-weight: 400; + font-style: normal; + font-display: swap; } +/* Every personal design is painted inside its own clipped button. */ +.magic-button { isolation: isolate; overflow: hidden; gap: 10px; } +.studio-launch { border-color: #baa4dc; color: #704ab1; background: #f0e7ff; } +.dark-mode .studio-launch { border-color: #8c72ba; color: #e3d2ff; background: #40304f; } +.magic-button > .magic-layer { position: absolute; inset: 0; z-index: -1; pointer-events: none; } +.magic-shader-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; pointer-events: none; } +.magic-shader-canvas[data-shader-state="fallback"] { visibility: hidden; } +.magic-layer::before, .magic-layer::after { content: ""; position: absolute; pointer-events: none; } +.magic-glitter::before { inset: 0; opacity: var(--magic-power); background-image: radial-gradient(circle, #fff 0 .7px, transparent 1.5px), radial-gradient(circle, #bceaff 0 .8px, transparent 1.7px), radial-gradient(circle, #fff9ce 0 .6px, transparent 1.3px); background-size: 13px 17px, 23px 19px, 31px 29px; animation: magic-glitter var(--magic-speed) linear infinite alternate; } +.magic-glitter::after { inset: -100%; opacity: calc(var(--magic-power) * .35); background: linear-gradient(110deg, transparent 35%, #ffffffb0 50%, transparent 65%); animation: magic-shimmer var(--magic-speed) ease-in-out infinite; } +.magic-plasma::before { inset: -80%; opacity: var(--magic-power); background: radial-gradient(ellipse at 25% 35%, var(--magic-color-1) 0, transparent 48%), radial-gradient(ellipse at 80% 70%, var(--magic-color-2, #04d9d2) 0, transparent 48%), radial-gradient(ellipse at 65% 10%, var(--magic-color-0) 0, transparent 65%); filter: blur(8px); animation: plasma-flow var(--magic-speed) ease-in-out infinite alternate; } +.magic-plasma::after { inset: -80%; opacity: calc(var(--magic-power) * .7); background: repeating-radial-gradient(ellipse at 50% 20%, var(--magic-color-2, #04d9d2) 0, var(--magic-color-1) 12%, transparent 30%); mix-blend-mode: screen; filter: blur(7px); animation: plasma-flow calc(var(--magic-speed) * 1.4) ease-in-out -7s infinite alternate-reverse; } +.magic-button .magic-label { display: flex; flex: 1 1 0; justify-content: flex-start; min-width: 0; z-index: 1; pointer-events: none; } +.magic-angle { display: block; max-width: 100%; } +.magic-name { position: relative; display: block; max-width: 100%; isolation: isolate; } +.magic-name.has-label-backdrop { padding: var(--label-backdrop-padding); border-radius: var(--label-backdrop-radius); } +.magic-name.has-label-backdrop::before { content: ""; position: absolute; inset: 0; z-index: -1; border-radius: inherit; background: var(--label-backdrop-color); opacity: var(--label-backdrop-opacity); pointer-events: none; } +.magic-button .Buttontext { display: block; width: auto; color: var(--magic-ink); font-family: var(--magic-font); font-size: var(--magic-size); font-weight: var(--magic-weight); font-synthesis: var(--magic-synthesis, none); line-height: 1.15; } +.magic-button .magic-text-gradient { color: transparent !important; background-clip: text; -webkit-background-clip: text; } +.magic-icon { position: relative; display: flex; flex-shrink: 0; align-items: center; justify-content: center; overflow: hidden; border-radius: 8px; pointer-events: none; z-index: 1; } +.magic-icon-image { display: block; width: 100%; height: 100%; object-fit: cover; } +.magic-symbol { display: block; line-height: 1; color: var(--magic-ink); } +.magic-spin { animation: magic-spin linear infinite; transform-origin: center; } +.magic-button .extra { position: relative; z-index: 2; flex: 0 0 auto; margin-left: auto; background: #f1f6f2ee; color: #234f3c; } +.magic-button .extra.rood { color: #a33342; background: #fce6e9; } +.magic-button .extra.orange { color: #8b5512; background: #fff0d9; } +.magic-neutral .magic-layer { background: #e7edf3 !important; } +.dark-mode .magic-neutral .magic-layer { background: #233448 !important; } +.dark-mode .magic-neutral .Buttontext { color: #e1eaf3; } +/* Quiet account lists keep every saved design intact; the studio previews it. */ +.button-styles-off .magic-button:not(.magic-preview) { color: #263e52; background: #e7edf3; } +.button-styles-off .magic-button:not(.magic-preview) > .magic-layer, +.button-styles-off .magic-button:not(.magic-preview) > .magic-icon, +.button-styles-off .magic-button:not(.magic-preview) .magic-name::before { display: none; } +.button-styles-off .magic-button:not(.magic-preview) .magic-angle { transform: none !important; } +.button-styles-off .magic-button:not(.magic-preview) .magic-name { padding: 0; animation: none !important; } +.button-styles-off .magic-button:not(.magic-preview) .Buttontext { color: #263e52 !important; background-image: none !important; font: 700 17px/1.15 system-ui, sans-serif !important; } +.dark-mode.button-styles-off .magic-button:not(.magic-preview) { color: #e1eaf3; background: #233448; } +.dark-mode.button-styles-off .magic-button:not(.magic-preview) .Buttontext { color: #e1eaf3 !important; } +@keyframes magic-spin { to { transform: rotate(360deg); } } +@keyframes magic-glitter { to { background-position: 47px 31px, -23px 41px, 31px -29px; } } +@keyframes magic-shimmer { 0%, 20% { transform: translateX(-35%); } 80%, 100% { transform: translateX(35%); } } +.Knopje.abort:not(.topknop), .Knopje.remove { color: #a33342; border-color: #edc9ce; background: #fff1f2; } +.Knopje.ok, .Knopje.enter, .Knopje.knopjes, .Knopje.back { color: #194d3a; border-color: #b9d9c7; background: #dff0e7; } +.Knopje.undo, .Knopje.restore { color: #815715; border-color: #ead9b9; background: #fff4df; } +/* Activity stays visible while products, accounts, and prompts change. */ +.activity { display: flex; flex: 0 0 58px; min-height: 0; align-items: center; gap: 14px; padding: 10px 16px; overflow: hidden; border-top: 1px solid var(--line); background: #e9eef0; } +.activity-label { flex: 0 0 auto; color: #697c89; font-size: 9px; font-weight: 750; letter-spacing: 1.5px; } +.activity-label::before { content: ""; display: inline-block; width: 5px; height: 5px; margin-right: 7px; border-radius: 50%; background: #789c89; } +#Log { flex: 1; max-height: 100%; overflow: auto; color: #546572; font-size: 12px; line-height: 1.6; overflow-wrap: anywhere; } +#Log .lastlog { color: #253b48; } -.Knopje { - align-items: flex-start; - background-color: #337ab7; - background-image: none; - border-bottom-color: #337ab7; - border-bottom-left-radius: 3px; - border-bottom-right-radius: 3px; - border-bottom-style: none; - border-bottom-width: 0px; - border-image-outset: 0px; - border-image-repeat: stretch; - border-image-slice: 100%; - border-image-source: none; - border-image-width: 1; - border-left-color: #337ab7; - border-left-style: none; - border-left-width: 0px; - border-right-color: #337ab7; - border-right-style: none; - border-right-width: 0px; - border-top-color: #337ab7; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - border-top-style: none; - border-top-width: 0px; - box-shadow: none; - box-sizing: border-box; - color: #FFF; - cursor: pointer; - direction: ltr; - display: inline-block; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 14px; - font-style: normal; - font-variant: normal; - font-weight: bold; - height: 13vh; - letter-spacing: normal; -/* line-height: 80px; */ - margin-bottom: 0px; - margin-left: 0px; - margin-right: 5px; - margin-top: 5px; - min-width: 13vw; - outline-color: #FFF; - outline-style: none; - outline-width: 0px; - padding-bottom: 9px; - padding-left: 12px; - padding-right: 12px; - padding-top: 9px; +/* A receipt uses its own columns, never viewport-relative text positions. */ +.receipt-panel { position: relative; - text-align: center; - text-decoration: none solid rgb(255, 255, 255); - text-indent: 0px; - text-shadow: none; - text-transform: none; - vertical-align: middle; - width: 120px; - word-spacing: 0px; - writing-mode: lr-tb; -} -.Button { - background-color: #539ad7; - height: 12vh; - margin-left: 5px; - margin-right: 5px; - margin-top: 5px; - min-width: 12.4vw; - padding-top: 1px; -} + display: flex; + width: var(--receipt-width); + min-height: 0; + flex-direction: column; + padding: 26px 22px 20px; + border-radius: 10px 10px 0 0; + background: #fffdf8; + box-shadow: 0 8px 28px #00000014; +} +.receipt-panel::after { content: ""; position: absolute; right: 0; bottom: -7px; left: 0; height: 8px; background: linear-gradient(135deg, #fffdf8 25%, transparent 25%) -8px 0 / 16px 16px, linear-gradient(225deg, #fffdf8 25%, transparent 25%) -8px 0 / 16px 16px; } +.receipt-heading { position: relative; padding-bottom: 22px; } +.eyebrow { color: #7f8078; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 10px; letter-spacing: 2px; } +.receipt-heading h2 { margin-top: 5px; color: #27322e; font-size: 28px; font-weight: 750; letter-spacing: -1px; } +.receipt-currency { position: absolute; right: 0; bottom: 26px; padding: 3px 6px; border: 1px solid #deded4; border-radius: 4px; color: #747a71; font-size: 10px; letter-spacing: 1px; } +.receipt-columns { display: grid; grid-template-columns: minmax(0, 1fr) 46px 85px; padding: 12px 0; border-top: 1px dashed #cdd1c7; border-bottom: 1px dashed #cdd1c7; color: #7a8076; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 9px; letter-spacing: 1px; } +.receipt-columns span:not(:first-child) { text-align: right; } +#Receipt2 { flex: 1 1 0; min-height: 70px; padding: 7px 0; overflow: auto; } +.Itemline { display: grid; grid-template-columns: minmax(0, 1fr) 24px 28px 10px minmax(56px, max-content); align-items: baseline; gap: 3px; padding: 14px 0; border-bottom: 1px solid #e6e7df; color: #3e4942; font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.5; animation: receipt-in 180ms ease-out; } +.Product { min-width: 0; overflow-wrap: anywhere; } +.Times, .ItemAmount, .Totalamount, .NewSaldo { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.LoseOrGain, .GainOrLose { color: #7b8378; font-size: 8px; letter-spacing: .2px; } +.ItemEuro::after, .TotalEuro::after { content: "€"; color: #7b8378; font-size: 10px; } +.ItemAmount { font-weight: 650; } +.receipt-empty { height: 100%; min-height: 170px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; padding: 20px 0; color: #83897f; font-size: 12px; text-align: center; } +.receipt-empty strong { margin-top: 8px; color: #626e62; font-size: 14px; font-weight: 550; } +.empty-symbol { display: grid; place-items: center; width: 46px; height: 54px; border: 1px dashed #c3cbbb; border-radius: 5px; color: #91a18a; font-family: ui-monospace, monospace; font-size: 26px; transform: rotate(-6deg); } +#Totals { flex: 0 1 auto; max-height: 30%; min-height: 0; overflow: auto; padding: 14px 0; border-top: 1px dashed #bfc8b9; } +.Userline { display: grid; grid-template-columns: minmax(0, 1fr) 30px 12px max-content; align-items: baseline; gap: 4px; padding: 8px 0; font-size: 15px; font-weight: 650; } +.UserName { min-width: 0; overflow-wrap: anywhere; } +.welcome { padding: 12px 0; color: #7a8276; font-size: 12px; font-weight: 400; line-height: 1.6; } +.receipt-footer { display: flex; align-items: center; flex-direction: column; gap: 10px; padding-top: 16px; color: #8b8e81; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 8px; letter-spacing: 1.2px; } +.receipt-barcode { width: 112px; height: 22px; opacity: .3; background: repeating-linear-gradient(90deg, #425142 0 1px, transparent 1px 3px, #425142 3px 5px, transparent 5px 7px, #425142 7px 8px, transparent 8px 12px, #425142 12px 15px, transparent 15px 17px); } -.abort { - background-color: #B90000; -} -.back { - background-color: #00B900; -} -.ok { - background-color: #00B900; -} -.knopjes { - background-color: #00B900; -} -#Secondscreen { - height: 95vh; - width: 100vw; - width: 100%; - background-color: #f8f8f8; - position: absolute; - top: 0px; - left: 0px; - z-index: 300; - } +/* The command line is always on screen and retains the original input. */ +#Invoer { flex: 0 0 auto; } +.command-caption { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 8px; color: #a0adbc; font-size: 10px; } +.command-caption label { font-weight: 700; letter-spacing: 1.8px; } +.command-help { color: #95a5b7; font-size: 11px; } +.command-input { display: flex; align-items: center; gap: 16px; min-height: 64px; padding: 8px 16px; border: 1px solid #4b5d70; border-radius: 10px; background: #080f19; transition: border-color 180ms, box-shadow 180ms; } +.command-input:focus-within { border-color: #ffbf69b3; box-shadow: 0 0 0 3px #ffbf690a, inset 0 0 32px #ffbf6903; } +.command-prompt { flex: 0 0 auto; color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 24px; font-weight: 700; } +#Zoek { flex: 1; min-width: 0; width: 100%; padding: 8px 0; border: 0; outline: 0; background: transparent; color: #f8fafc; caret-color: var(--accent); font-family: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace; font-size: 20px; line-height: 1.4; } +#Zoek::placeholder { color: #9aabba; opacity: 1; } +.enter-hint { flex: 0 0 auto; min-width: 88px; min-height: 40px; padding: 8px 12px; border: 1px solid #4b5d70; border-bottom-width: 3px; border-radius: 6px; background: #172434; color: #e4edf7; cursor: pointer; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 11px; letter-spacing: 1px; transition: border-color 140ms, background-color 140ms, transform 140ms; } +.enter-hint:hover { border-color: var(--accent); background: #273547; color: var(--accent); } +.enter-hint:active { transform: translateY(1px); } +.enter-hint:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } -#IRCwindow { - left: 0.5vw; - top: 13vh; - width: 99%; - background-color: #ffffff; - position: absolute; - height: 83vh; - z-index: 542; -} -#spacewindow { - left: 0.5vw; - top: 13vh; - width: 99%; - background-color: #ffffff; - position: absolute; - height: 83vh; - z-index: 542; -} -#LeftButtons { - left: 2vh; - top: 2.5vh; - width: 100%; - background-color: #f8f8f8; - position: absolute; - height: 13vh; -} - -#MainButtons { - left: 2vh; - width: 66vw; - height: 65vh; - top: 26vh; - background-color: #FFFFFF; - position: absolute; -} +/* Existing special screens fit inside the same left column. */ +.sound-screen { display: flex; flex-direction: column; gap: 16px; } +.sound-actions { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; } +.sound-actions .Knop { min-height: 104px; } +.pos-sound-settings-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 12px; } +.pos-sound-settings h2 { margin: 0; font-size: 19px; } +.pos-sound-settings .profile-hint { margin: 4px 0 0; } +.pos-sound-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 12px; } +.pos-sound-card { display: flex; min-width: 0; flex-direction: column; gap: 10px; padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } +.pos-sound-label { color: var(--ink); font-size: 15px; font-weight: 700; } +.pos-sound-actions { display: flex; gap: 8px; } +.pos-sound-actions .sound-reset { flex: 1; } +.pos-sound-select { width: 100%; min-width: 0; min-height: 56px; padding: 10px; border: 1px solid #a6bdcc; border-radius: 7px; background: #f4f7fa; color: #243d50; font: inherit; font-size: 14px; cursor: pointer; } +.sound-reset { min-height: 44px; padding: 8px 12px; border: 1px solid #cdd9e1; border-radius: 7px; background: #edf2f6; color: #263f53; font: inherit; font-size: 13px; font-weight: 650; cursor: pointer; } +.sound-reset:disabled, .pos-sound-select:disabled { opacity: .5; cursor: default; } +.pos-sound-status { min-height: 20px; margin: 8px 0 0; color: var(--muted); font-size: 13px; } +.audio-volume-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; } +.audio-volume-card { min-width: 0; padding: 22px; border: 1px solid #c8d7e0; border-radius: 12px; background: #fff; box-shadow: 0 3px 12px #1a344908; } +.audio-volume-heading { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; } +.audio-volume-label { color: #243d50; font-size: 19px; font-weight: 750; } +.audio-volume-value { color: #244f70; font-family: ui-monospace, monospace; font-size: 28px; font-weight: 750; } +.audio-volume-hint { margin-top: 4px; color: var(--muted); font-size: 13px; } +.audio-slider { display: block; width: 100%; height: 72px; margin: 8px 0 0; padding: 0; appearance: none; background: transparent; cursor: pointer; touch-action: none; } +.audio-slider::-moz-range-track { height: 12px; border-radius: 8px; background: linear-gradient(to right, #32688f var(--volume-fill, 0%), #dfe7ed var(--volume-fill, 0%)); } +.audio-slider::-webkit-slider-runnable-track { height: 12px; border-radius: 8px; background: linear-gradient(to right, #32688f var(--volume-fill, 0%), #dfe7ed var(--volume-fill, 0%)); } +.audio-slider::-moz-range-thumb { width: 42px; height: 42px; border: 3px solid #fff; border-radius: 50%; background: #32688f; box-shadow: 0 2px 8px #173b5e55; } +.audio-slider::-webkit-slider-thumb { width: 48px; height: 48px; margin-top: -18px; border: 3px solid #fff; border-radius: 50%; appearance: none; background: #32688f; box-shadow: 0 2px 8px #173b5e55; } +.audio-slider:focus-visible { outline: 3px solid #db860d; outline-offset: 2px; border-radius: 8px; } +.audio-slider:disabled { opacity: .4; cursor: default; } +.audio-volume-scale { display: flex; justify-content: space-between; color: var(--muted); font-size: 12px; } +.audio-status { min-height: 20px; margin-top: 12px; color: #56707f; font-size: 13px; } +.numbers { display: grid; width: min(100%, 420px); min-height: 100%; margin: 0 auto; grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-rows: repeat(4, minmax(64px, 1fr)); gap: 10px; } +.numbers br { display: none; } +.numbers .invoer { font-size: 30px; } +.keys { height: 100%; line-height: 0; } +.keys .small { display: inline-flex; width: calc(100% / 11 - 4px); min-height: 40px; height: calc(100% / 8 - 5px); margin: 2px; padding: 2px; border-radius: 5px; font-size: 17px; line-height: 1; vertical-align: top; } +.keys #space { width: calc(100% * 7 / 11 - 4px); } +.keys #leeg { visibility: hidden; } +.keys br { line-height: 0; } +.infoboxes { display: flex; min-height: 100%; flex-direction: column; gap: 10px; } +.infoboxes .Userline { display: grid; grid-template-columns: minmax(0, 1fr) 44px 14px max-content; gap: 8px; padding: 20px; border: 1px solid #c1d9cb; border-radius: 10px; background: #f7fcf9; font-size: 22px; } +.NowHas { grid-column: 1 / 3; margin-top: 14px; color: #698172; font-size: 13px; font-weight: 400; } +.NewSaldo { grid-column: 3 / 5; margin-top: 14px; color: #31634e; font-size: 20px; } +.NewSaldo::before { content: "€ "; } +.mylines, .lines { height: 100%; padding: 16px; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: #425364; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 13px; line-height: 1.8; } +/* Clip once at the panel's inner top edge; a second rounded white surface + behind the iframe creates a pale fringe, particularly in dark mode. */ +.embedded-screen { position: absolute; inset: 0; z-index: 2; display: flex; flex-direction: column; overflow: hidden; border-radius: 11px 11px 0 0; background: var(--panel); } +.embedded-screen iframe { flex: 1 1 0; width: 100%; min-height: 0; border-radius: 0; background: transparent; } -#TopButtons { - position: absolute; - left: 2vh; - top: 13vh; - width: 100%; - height: 13vh; - background-color: #f8f8f8; - text-align: center; -} -.Knop { - height: 12vh; - margin-left: 5px; - margin-right: 5px; - margin-top: 5px; - min-width: 12.0vw; - padding-top: 1px; - position: relative; -} -.Buttontext { - position: relative; -} -.Knopjetext { - position: relative; -} -.numbers { - left: 28%; - position: absolute; - width: 70%; -} -.invoer { - font-size: 80px; - line-height: 80px; -} -.extra { - padding-left: 1vw; - padding-right: 1vw; - background-color: #00b000; - border-radius: 0.5vw; - bottom: 0; - font-size: 2.5vh; - position: absolute; -} -.rood { - background-color: red; -} -.orange { - background-color: #FF8300; -} +@keyframes receipt-in { from { opacity: .3; transform: translateY(-5px); } to { opacity: 1; transform: none; } } -.right { - right: 0; -} +/* Grouped commands, persistent settings and compact account history. */ +.command-tab, .archive-tab, .profile-action { min-height: 56px; padding: 10px 16px; border: 1px solid #cdd9e1; border-radius: 8px; background: #edf2f6; color: #263f53; font: inherit; font-size: 14px; font-weight: 650; cursor: pointer; } +.command-tab { flex: 1 1 auto; padding: 8px 11px; white-space: nowrap; } +.category-icon { display: inline-block; margin-right: 7px; font-size: 17px; line-height: 1; letter-spacing: 0; vertical-align: middle; } +.command-tab.selected, .archive-tab.selected { color: #1c3f59; border-color: #91adc2; background: #dce9f3; } +.archive-tab:disabled { opacity: .5; cursor: default; } +.archive-tab.archive { margin-left: auto; } +.account-display-tools { display: flex; flex: 0 0 auto; align-items: stretch; gap: 8px; margin-left: auto; } +.button-styles-toggle { flex: 0 0 56px; width: 56px; padding: 8px; font-size: 24px; } +.button-styles-toggle[aria-pressed="false"] { color: #1c3f59; background: #dce9f3; border-color: #91adc2; } +.abort-all { background: #fff0f1; border-color: #edc9ce; color: #a33342; white-space: nowrap; } +.command-sections { display: grid; grid-template-columns: minmax(0,1fr); gap: 12px; align-items: start; } +.command-section { padding: 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; min-width: 0; } +.command-section h2 { margin: 0 0 10px; color: #607484; font-size: 12px; font-weight: 750; letter-spacing: 1px; text-transform: uppercase; } +.command-group-buttons { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 7px; } +.command-group-buttons .Knop { min-height: 84px; padding: 8px 10px; font-size: 18px; justify-content: flex-start; text-align: left; } +.command-group-buttons .Buttontext { font-size: 18px !important; } +.command-group-buttons .Knop:has(.command-hint) { flex-direction: column; align-items: flex-start; gap: 2px; } +.command-hint { color: var(--muted); font-size: 10px; font-weight: 400; } +.settings-grid { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 10px; } +.setting-toggle { display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 66px; padding: 12px 16px; border: 1px solid #cdd9e1; border-radius: 8px; background: #f4f7f9; color: var(--ink); font: inherit; font-size: 15px; font-weight: 650; text-align: left; cursor: pointer; } +.setting-state { flex-shrink: 0; font-size: 11px; padding: 6px 9px; border-radius: 20px; background: #e1e8ee; color: #536371; } +.setting-toggle[aria-checked="true"] { border-color: #77a78e; background: #e8f5ed; } +.setting-toggle[aria-checked="true"] .setting-state { background: #246849; color: #fff; } +.setting-toggle:disabled { opacity: .55; cursor: wait; } +.archived-account { filter: grayscale(.85); opacity: .62; } +.archived-account:hover { opacity: .95; } +.profile-heading, .profile-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-bottom: 18px; } +.profile-action.membership-action { margin-left: auto; color: #115370; background: #d9f1fb; border-color: #87cfe4; } +.profile-action.membership-action:hover { background: #c2e8f6; border-color: #5ab6d2; } +.infoboxes:has(+ .user-profile) { min-height: 0; margin-bottom: 18px; } +.profile-heading h1 { font-size: 28px; margin-right: auto; } +.profile-balance { padding: 12px 18px; border-radius: 8px; color: #285e49; background: #e2f0e8; font-family: ui-monospace,monospace; font-weight: 700; } +.profile-balance.rood { color: #a33342; background: #fce6e9; } +.profile-balance.orange { color: #8b5512; background: #fff0d9; } +.user-profile h2 { font-size: 18px; } +.profile-hint { color: var(--muted); font-size: 13px; margin: 7px 0 14px; } +.purchase-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } +.purchase-row { display: flex; align-items: center; gap: 16px; justify-content: space-between; min-height: 70px; padding: 10px 16px; border: 1px solid var(--line); border-radius: 8px; background: #fff; } +.purchase-date { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; } +.reorder-purchase { min-width: 168px; } +.dancing-banana, .click-hitmarker { position: fixed; z-index: 100; pointer-events: none; user-select: none; } +.dancing-banana { width: 40px; height: auto; opacity: 1; transition: opacity 2s linear; image-rendering: pixelated; } +.dancing-banana.fading { opacity: 0; } +.click-hitmarker { width: 70px; height: 70px; object-fit: contain; transform: translate(-50%,-50%); } +.confetti-canvas { position: fixed; inset: 0; z-index: 90; width: 100%; height: 100%; pointer-events: none; } +button:focus-visible { outline: 3px solid #db860d; outline-offset: 2px; } -.left { - left: 0; -} -.small { - height: 7vh; - line-height: 6vh; - width: 5vw; - min-width: 5vw; - font-size: 6vh; - font-height: 6vh; -} -#leeg { - background-color: #fff; -} -#space { - min-width: 40vw; - width: 40vw; -} -.lines { - height: 70vh; - overflow: scroll; - font-size: 2.5vh; -} -.Question { -font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - border-top-width: 5px; - font-size: 5vh; - line-height: 5vh; - position: absolute; - top: 4vh; - width: 100%; -} -.cash { - left: 0; - position: absolute; -} -.account { - background-position: center; - background-repeat: no-repeat; - background-size: cover; -} -.account .Buttontext, -.account .extra { - text-shadow: 0 1px 2px #000; -} -.account.fullwidthButton { - width: 9.75vw; +/* Personal themes and debt colours remain recognizable in the dark panes. */ +body.dark-mode { --ink: #e1eaf3; --muted: #a4b6c9; --line: #3a4b60; --panel: #192637; } +.dark-mode #MainButtons { background-color: var(--panel) !important; } +.dark-mode #TopButtons, .dark-mode .activity { background: #202e40; color: #dce5ef; border-color: #3a4b60; } +.dark-mode .Question, .dark-mode .activity-label { color: #bdcddd; } +.dark-mode .receipt-panel { color: #e5edf4; background: #1b2938; border-color: #3a4b60; } +.dark-mode .receipt-panel::after { background: linear-gradient(135deg, #1b2938 25%, transparent 25%) -8px 0 / 16px 16px, linear-gradient(225deg, #1b2938 25%, transparent 25%) -8px 0 / 16px 16px; } +.dark-mode #Log, .dark-mode #Log .lastlog, .dark-mode .Itemline { color: #c4d3e0; } +.dark-mode .receipt-heading h2, .dark-mode .Product, .dark-mode .ItemAmount, .dark-mode .UserName, .dark-mode .Totalamount { color: #edf3f8; } +.dark-mode .receipt-columns, .dark-mode .receipt-footer, .dark-mode .receipt-currency, .dark-mode .eyebrow, .dark-mode .receipt-empty, .dark-mode .welcome { color: #b0c1d0; } +.dark-mode .receipt-empty strong { color: #c7d8e6; } +.dark-mode .receipt-barcode { filter: brightness(3); } +.dark-mode .Knopje:where(:not(.topknop)), +.dark-mode .command-section, .dark-mode .purchase-row, .dark-mode .audio-volume-card, .dark-mode .mylines, .dark-mode .lines, .dark-mode .infoboxes .Userline { color: #e1eaf3; background-color: #233448; border-color: #40546a; } +.dark-mode .command-section h2, .dark-mode .audio-volume-label, .dark-mode .audio-volume-value, .dark-mode .audio-status { color: #b8cee3; } +.dark-mode .command-tab, .dark-mode .archive-tab, .dark-mode .profile-action, .dark-mode .setting-toggle { color: #d9e5ef; background: #26394d; border-color: #445d73; } +.dark-mode .membership-action { color: #b8eaf9; background: #164459; border-color: #3b88a6; } +.dark-mode .membership-action:hover { background: #1d536a; border-color: #62a8c1; } +.dark-mode .command-tab.selected, .dark-mode .archive-tab.selected { background: #365675; border-color: #8eafcd; } +.dark-mode .setting-toggle[aria-checked="true"] { background: #214b3d; border-color: #76aa8f; } +.dark-mode .setting-toggle[aria-checked="true"] .setting-state { background: #76be99; color: #102f22; } +.dark-mode .profile-balance { color: #bce0ce; background: #244737; } +.dark-mode .profile-balance.rood { color: #ffbdca; background: #542f42; } +.dark-mode .profile-balance.orange { color: #f5d197; background: #4b3e2c; } +.dark-mode .pos-sound-card { background: #233448; border-color: #40546a; } +.dark-mode .pos-sound-select, .dark-mode .sound-reset { background: #26394d; color: #e1eaf3; border-color: #445d73; } +.dark-mode .Knopje .extra { color: #bce0ce; background: #244737; } +.dark-mode .Knopje .extra.rood { color: #ffbdca; background: #542f42; } +.dark-mode .Knopje .extra.orange { color: #f5d197; background: #4b3e2c; } +.dark-mode .profile-action.abort-all, .dark-mode .Knopje.abort:not(.topknop), .dark-mode .Knopje.remove { color: #ffbdca; background: #482d3a; border-color: #845362; } +.dark-mode .Knopje.undo, .dark-mode .Knopje.restore { color: #f5d197; background: #4b3e2c; border-color: #806746; } +.dark-mode .NewSaldo { color: #bce0ce; } +.dark-mode .NowHas, .dark-mode .LoseOrGain, .dark-mode .GainOrLose, .dark-mode .Times, .dark-mode .ItemEuro::after, .dark-mode .TotalEuro::after { color: #b0c1d0; } +.dark-mode .Itemline, .dark-mode .receipt-columns, .dark-mode .receipt-heading, .dark-mode #Totals { border-color: #46566a; } +@media (max-width: 1300px) { + .pos-sound-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .sound-actions { grid-template-columns: repeat(4,minmax(0,1fr)); } + .settings-grid { grid-template-columns: repeat(2,minmax(0,1fr)); } + .purchase-list { grid-template-columns: 1fr; } + .command-tab { flex-shrink: 0; } + .command-group-buttons { grid-template-columns: repeat(2,minmax(0,1fr)); } +} +@media (max-width: 620px) { + .pos-sound-grid { grid-template-columns: 1fr; } + .pos-sound-settings-heading { flex-wrap: wrap; } + .purchase-row { flex-wrap: wrap; } + .reorder-purchase { width: 100%; } + .settings-grid { grid-template-columns: 1fr; } +} +@keyframes plasma-flow { + from { transform: translate(-7%, -4%) rotate(-15deg) scale(1); filter: blur(9px) hue-rotate(0deg); } + to { transform: translate(7%, 6%) rotate(30deg) scale(1.25); filter: blur(9px) hue-rotate(100deg); } +} +@media (min-width: 1600px) and (min-height: 960px) { + :root { --button-columns: 8; --button-rows: 7; --buttons-per-page: 56; } + .Knop { font-size: 20px; } +} +@media (max-width: 1100px) { + :root { --gap: 12px; } + body { padding: 0 14px 14px; } + .app-header { gap: 14px; flex-basis: 74px; } + .brand-logo { width: 68px; height: 46px; } + .brand-name { font-size: 21px; } + .brand-caption { font-size: 8px; letter-spacing: .6px; } + .topknop { flex-direction: column; gap: 5px; min-height: 56px; padding: 6px 4px; font-size: 12px; } + .nav-icon { flex-basis: 14px; } + .nav-icon svg { width: 14px; height: 14px; } + #TopButtons { min-height: 80px; padding: 10px 12px; } + #MainButtons { padding: 12px; } + .Pagina { gap: 8px; } + .Knop { padding: 10px; font-size: 16px; } + .activity { flex-basis: 50px; padding: 8px 12px; } + #Zoek { font-size: 18px; } +} +@media (max-width: 850px) { + :root { --receipt-width: 288px; } + .brand-caption, .nav-icon { display: none; } + .brand-name { font-size: 18px; } + .brand-logo { width: 50px; height: 34px; } + .app-header { gap: 10px; } + #LeftButtons { gap: 2px; } + .topknop { min-height: 42px; font-size: 11px; } + :root { --button-columns: 4; --button-rows: 7; } + .receipt-panel { padding: 20px 16px 16px; } + .receipt-heading h2 { font-size: 25px; } + .Knop { font-size: 15px; } + .command-help { font-size: 10px; } + #Zoek { font-size: 16px; } + .audio-volume-grid { grid-template-columns: 1fr; } + .audio-volume-card { padding: 16px; } + .sound-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 620px) { + body { padding: 0 10px 12px; overflow: auto; } + .app-header { flex-wrap: wrap; flex-basis: auto; gap: 10px; padding: 14px 0 12px; } + .brand { flex: 0 0 100%; } + .brand-caption { display: block; } + .brand-logo { width: 64px; height: 42px; } + #LeftButtons { flex: 0 0 100%; flex-wrap: wrap; gap: 5px; } + .topknop, .topknop.abort { flex: 1 1 22%; min-height: 36px; } + .workspace { flex: 1 0 auto; flex-wrap: wrap; } + .workspace-main { flex: 0 0 100%; height: 460px; } + :root { --button-columns: 3; --button-rows: 9; } + .receipt-panel { width: 100%; min-height: 330px; max-height: 480px; } + #Receipt2 { flex-basis: 130px; } + .command-help { display: none; } + #Invoer { position: sticky; bottom: 0; z-index: 3; padding: 10px 0 0; background: var(--shell); } + .command-input { min-height: 58px; gap: 10px; padding: 8px 12px; } + #Zoek { font-size: 14px; } + .enter-hint { min-width: 64px; padding: 5px; font-size: 9px; } +} +@media (max-height: 700px) and (min-width: 621px) { + .app-header { flex-basis: 64px; } + #TopButtons { min-height: 54px; padding-top: 6px; padding-bottom: 6px; } + .activity { flex-basis: 42px; } + .command-input { min-height: 54px; } + .Pagina { grid-template-rows: repeat(var(--button-rows), minmax(64px, 1fr)); } + .Knop { min-height: 64px; } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } -.activetop { - background-color: #161650; -} -.Spaceknop { - min-width: 20vw; - height: 18vh; -} -.dot { - height: 3vw; - width: 3vw; - background-color: orange; - border-radius: 50%; - display: inline-block; - position: absolute; - top: 0px; - left: 0px; -} -.slider { - background: #d3d3d3; - height: 100%; - width: 25px; - background: #d3d3d3; - outline: none; - opacity: 0.7; - -webkit-transition: .2s; - transition: opacity .2s; -} -.slider::-moz-range-thumb { - width: 25px; - height: 25px; - background: #4CAF50; +/* Payment codes always use a white surface, including in dark mode. */ +.deposit-payment { + box-sizing: border-box; + width: min(860px, calc(100vw - 24px)); + max-height: calc(100dvh - 24px); + padding: 24px; + border: 0; + border-radius: 8px; + background: #fff; + color: #172017; + overflow: auto; +} +.deposit-payment::backdrop { background: rgba(0, 0, 0, 0.65); } +.deposit-payment h2 { margin: 0 0 16px; font-size: 24px; } +.deposit-payment-entry { + display: grid; + grid-template-columns: minmax(0, 400px) minmax(0, 1fr); + gap: 24px; + align-items: center; +} +.deposit-payment-qr { + display: block; + width: 100%; + height: auto; + aspect-ratio: 1; + image-rendering: pixelated; + background: #fff; +} +.deposit-payment-details { margin: 0; overflow-wrap: anywhere; } +.deposit-payment-details dt { margin-top: 16px; font-size: 14px; color: #465246; } +.deposit-payment-details dd { margin: 4px 0 0; font-size: 22px; font-weight: 600; } +.deposit-payment-close { + display: block; + min-height: 48px; + margin: 20px 0 0 auto; + padding: 10px 24px; + border: 1px solid #36563d; + border-radius: 4px; + background: #edf4ee; + color: #172017; + font: inherit; cursor: pointer; } -.topknop { - min-width: 9vw; - margin-left: 0px; -} - -.slider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 25px; - height: 25px; - background: #4CAF50; - cursor: pointer; +@media (max-width: 640px) { + .deposit-payment { padding: 16px; } + .deposit-payment-entry { grid-template-columns: minmax(0, 1fr); gap: 8px; } + .deposit-payment-qr { max-width: 400px; margin: auto; } + .deposit-payment-details dd { font-size: 18px; } } diff --git a/www/stream.php b/www/stream.php index 7d9bae7..25207c8 100644 --- a/www/stream.php +++ b/www/stream.php @@ -42,6 +42,8 @@ function procmsg($topic,$msg){ exit(1); } $topics['hack42bar/output/session/'.$session.'/#'] = array("qos"=>0, "function"=>"procmsg"); +$topics['hack42bar/output/settings/sounds'] = array("qos"=>0, "function"=>"procmsg"); +$topics['hack42bar/output/settings/buttons/+'] = array("qos"=>0, "function"=>"procmsg"); $mqtt->subscribe($topics,0); echo "retry: 1000\n"; echo "data: ".json_encode(array("startup","1"))."\n\n";