diff --git a/.gitignore b/.gitignore index 8ea0457..9dd96f9 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,9 @@ www-test/ ## TeaVM: # Not sure yet... +## Cloudflare Wrangler: +.wrangler/ + ## IntelliJ, Android Studio: .idea/ *.ipr diff --git a/README.md b/README.md index d56644e..1b0102d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ The UI of JOric has been designed primarily with mobile devices in mind, so give - e.g. [https://oric.games/?url=https://defence-force.org/files/space1999-en.zip](https://oric.games/?url=https://defence-force.org/files/space1999-en.zip) - Support for loading games attached to forum posts: - e.g. [https://oric.games/?url=https://forum.defence-force.org/download/file.php?id=4084](https://oric.games/?url=https://forum.defence-force.org/download/file.php?id=4084) +- Support for specifying the ROM to use for individual game loads via a `?rom=` request parameter: + - e.g. [https://oric.games/?rom=oric1#/basic](https://oric.games/?rom=oric1#/basic) + - e.g. `https://oric.games/?rom=oric1&url=https://example.com/my-oric1-game.tap` + - Valid values are `atmos` (BASIC 1.1, the default) and `oric1` (BASIC 1.0). - Being a PWA (Progressive Web App), it can be installed locally to your device! - And it also comes as a standalone Java app, for those who prefer Java. diff --git a/android/src/main/java/emu/joric/android/AndroidJOricRunner.java b/android/src/main/java/emu/joric/android/AndroidJOricRunner.java index e06bc2d..0ef52d6 100644 --- a/android/src/main/java/emu/joric/android/AndroidJOricRunner.java +++ b/android/src/main/java/emu/joric/android/AndroidJOricRunner.java @@ -11,6 +11,7 @@ import emu.joric.MachineType; import emu.joric.PixelData; import emu.joric.Program; +import emu.joric.RomConfig; import emu.joric.config.AppConfigItem; import emu.joric.cpu.Cpu6502; import emu.joric.memory.RamType; @@ -61,7 +62,9 @@ private void runProgram(AppConfigItem appConfigItem, Program program) { machine = new Machine(psg, keyboardMatrix, pixelData); // Load the ROM files. - byte[] basicRom = Gdx.files.internal("roms/basic11b.rom").readBytes(); + RomConfig.Option romOpt = RomConfig.resolveRom( + appConfigItem, Gdx.app.getPreferences("joric.preferences")); + byte[] basicRom = Gdx.files.internal("roms/" + romOpt.filename).readBytes(); byte[] microdiscRom = Gdx.files.internal("roms/microdis.rom").readBytes(); machine.init(basicRom, microdiscRom, program, diff --git a/android/src/main/java/emu/joric/android/AndroidLauncher.java b/android/src/main/java/emu/joric/android/AndroidLauncher.java index 54a161c..4487cfe 100644 --- a/android/src/main/java/emu/joric/android/AndroidLauncher.java +++ b/android/src/main/java/emu/joric/android/AndroidLauncher.java @@ -210,6 +210,59 @@ public void onClick(DialogInterface dialog, int which) { }); } + @Override + public void promptForOption(final String title, final String message, final String[] options, + final String currentSelection, final TextInputResponseHandler textInputResponseHandler) { + runOnUiThread(new Runnable() { + @Override + public void run() { + int initialIndex = -1; + if (currentSelection != null) { + for (int i = 0; i < options.length; i++) { + if (currentSelection.equals(options[i])) { + initialIndex = i; + break; + } + } + } + final int[] chosen = { initialIndex }; + + AlertDialog.Builder builder = new AlertDialog.Builder(AndroidLauncher.this); + if (title != null && !title.isEmpty()) { + builder.setTitle(title); + } + if (message != null && !message.isEmpty()) { + builder.setMessage(message); + } + builder.setSingleChoiceItems(options, initialIndex, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + chosen[0] = which; + } + }); + builder.setPositiveButton("OK", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + if (chosen[0] >= 0 && chosen[0] < options.length) { + textInputResponseHandler.inputTextResult(true, options[chosen[0]]); + } else { + textInputResponseHandler.inputTextResult(false, null); + } + dialog.cancel(); + } + }); + builder.setNegativeButton("Cancel", new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + textInputResponseHandler.inputTextResult(false, null); + dialog.cancel(); + } + }); + builder.create().show(); + } + }); + } + @Override public boolean isDialogOpen() { // Not required for Android, so simply return false regardless of state. diff --git a/assets/roms/basic10.rom b/assets/roms/basic10.rom new file mode 100644 index 0000000..f2462ec Binary files /dev/null and b/assets/roms/basic10.rom differ diff --git a/core/src/main/java/emu/joric/HomeScreen.java b/core/src/main/java/emu/joric/HomeScreen.java index 189a659..137afb1 100644 --- a/core/src/main/java/emu/joric/HomeScreen.java +++ b/core/src/main/java/emu/joric/HomeScreen.java @@ -31,6 +31,7 @@ import com.badlogic.gdx.scenes.scene2d.ui.Label; import com.badlogic.gdx.scenes.scene2d.ui.Skin; import com.badlogic.gdx.scenes.scene2d.ui.Table; +import com.badlogic.gdx.scenes.scene2d.ui.WidgetGroup; import com.badlogic.gdx.scenes.scene2d.utils.ClickListener; import com.badlogic.gdx.scenes.scene2d.utils.Drawable; import com.badlogic.gdx.utils.Align; @@ -223,10 +224,15 @@ private void addAppButtonsToStage(Stage stage, PaginationWidget paginationWidget Button infoButton = buildButton("INFO", null, "png/info.png", 96, 96, null, null); currentPage.add().expandX(); - currentPage.add(infoButton).pad(30, 0, 0, 20).align(Align.right).expandX(); + // Right pad is 40 (not 20) so the info button's right edge lines up + // horizontally with the ADD button's right edge. The ADD button sits + // inside a 116-wide WidgetGroup (with the mini settings cog occupying + // the group's bottom-right corner), which pushes ADD's right edge + // 20 px further in from the window edge than the cog. + currentPage.add(infoButton).pad(30, 0, 0, 40).align(Align.right).expandX(); currentPage.row(); currentPage.add().expandX(); - + if (viewportManager.isLandscape()) { // Landscape. container.setBackground(new Image(backgroundLandscape).getDrawable()); @@ -244,11 +250,46 @@ private void addAppButtonsToStage(Stage stage, PaginationWidget paginationWidget float titlePadding = ((1080 - titleWidth) / 2); currentPage.add(title).width(titleWidth).height(197).pad(0, titlePadding, 0, titlePadding).expand(); } - + Button addButton = buildButton("ADD", null, "png/open_file.png", 96, 96, null, null); + + // Mini settings cog tucked into the bottom-right corner of the ADD + // button. The icon is loaded fresh (bypassing buildButton's texture + // cache) so the 16x16 native PNG upscales cleanly at 2x to 32x32 with + // nearest-neighbour filtering. + // + // Both icons are visually circular inside their square bounds (with + // transparent padding around the visible art), so the squares can + // overlap a little at the corner without the visible circles touching. + // The group is sized so a small visual gap remains between the two + // icons when rendered. Tune by adjusting GROUP_SIZE: larger = bigger + // gap, smaller = icons closer (and eventually overlapping). + Texture miniSettingsTexture = new Texture(Gdx.files.internal("png/settings.png")); + miniSettingsTexture.setFilter(TextureFilter.Nearest, TextureFilter.Nearest); + Image miniSettingsIcon = new Image(miniSettingsTexture); + miniSettingsIcon.setName("SETTINGS"); + miniSettingsIcon.addListener(appClickListener); + + final int GROUP_SIZE = 116; + final int ADD_SIZE = 96; + final int COG_SIZE = 32; + WidgetGroup addGroup = new WidgetGroup() { + @Override + public float getPrefWidth() { return GROUP_SIZE; } + @Override + public float getPrefHeight() { return GROUP_SIZE; } + }; + addGroup.setSize(GROUP_SIZE, GROUP_SIZE); + addButton.setSize(ADD_SIZE, ADD_SIZE); + addButton.setPosition(0, GROUP_SIZE - ADD_SIZE); // top-left (libGDX y grows upward) + addGroup.addActor(addButton); + miniSettingsIcon.setSize(COG_SIZE, COG_SIZE); + miniSettingsIcon.setPosition(GROUP_SIZE - COG_SIZE, 0); // bottom-right + addGroup.addActor(miniSettingsIcon); + currentPage.row(); currentPage.add().expandX(); - currentPage.add(addButton).pad(0, 0, 30, 20).align(Align.right).expandX(); + currentPage.add(addGroup).pad(0, 0, 30, 20).align(Align.right).expandX(); PagedScrollPane pagedScrollPane = new PagedScrollPane(); pagedScrollPane.setHomeScreen(this); @@ -765,6 +806,8 @@ public void clicked(InputEvent event, float x, float y) { showAboutJOricDialog(); } else if (appName.equals("ADD")) { importProgram(); + } else if (appName.equals("SETTINGS")) { + showSettingsDialog(); } } } @@ -812,6 +855,30 @@ public void inputTextResult(boolean success, String button) { }); } + private void showSettingsDialog() { + final RomConfig.Option current = RomConfig.getSelected(joric.getPreferences()); + final String[] displayNames = new String[RomConfig.OPTIONS.length]; + for (int i = 0; i < RomConfig.OPTIONS.length; i++) { + displayNames[i] = RomConfig.OPTIONS[i].displayName; + } + dialogHandler.promptForOption("Settings", "ROM for local files:", displayNames, current.displayName, + new TextInputResponseHandler() { + @Override + public void inputTextResult(boolean success, String text) { + if (!success || text == null) { + return; + } + // Look up the Option whose displayName matches the chosen string. + for (RomConfig.Option option : RomConfig.OPTIONS) { + if (option.displayName.equals(text)) { + RomConfig.setSelected(joric.getPreferences(), option.id); + return; + } + } + } + }); + } + private void exportState() { } diff --git a/core/src/main/java/emu/joric/RomConfig.java b/core/src/main/java/emu/joric/RomConfig.java new file mode 100644 index 0000000..3362846 --- /dev/null +++ b/core/src/main/java/emu/joric/RomConfig.java @@ -0,0 +1,165 @@ +package emu.joric; + +import com.badlogic.gdx.Preferences; + +import emu.joric.config.AppConfigItem; + +/** + * Configuration of available ROM options for JOric. + * + * The user can select which ROM the emulator loads via a settings dialog. + * Selection is persisted in JOric's Preferences under KEY_SELECTED_ROM. + * + * To add a new ROM option: + * 1. Place the .rom file in assets/roms/ + * 2. Add a new entry to OPTIONS below + */ +public final class RomConfig { + + /** A single ROM choice exposed to the user. */ + public static final class Option { + public final String id; // stable identifier stored in preferences + public final String displayName; + public final String filename; // filename in assets/roms/ + + public Option(String id, String displayName, String filename) { + this.id = id; + this.displayName = displayName; + this.filename = filename; + } + } + + /** Available ROM options. The first entry is the default. */ + public static final Option[] OPTIONS = { + new Option("atmos", "Oric Atmos (BASIC 1.1)", "basic11b.rom"), + new Option("oric1", "Oric-1 (BASIC 1.0)", "basic10.rom"), + }; + + /** Preference key under which the selected ROM id is stored. */ + public static final String KEY_SELECTED_ROM = "selected_rom"; + + /** + * ROM id captured from the page's {@code ?rom=} URL parameter. Populated + * by the GWT platform at page load when the URL is launching a specific + * program. Cleared by {@link #takeUrlRomParam()} when consumed, so only + * the launch that originated with the URL uses it — subsequent launches + * fall through to the normal resolution path. + */ + private static String urlRomParam; + + private RomConfig() {} + + /** + * Returns the currently selected ROM Option, falling back to the default + * (first option) if no preference is set or the stored id is unknown. + */ + public static Option getSelected(Preferences preferences) { + String id = preferences != null ? preferences.getString(KEY_SELECTED_ROM, null) : null; + if (id != null) { + for (Option opt : OPTIONS) { + if (opt.id.equals(id)) return opt; + } + } + return OPTIONS[0]; + } + + /** + * Save the selected ROM id to preferences. Pass an Option.id (not displayName). + */ + public static void setSelected(Preferences preferences, String id) { + if (preferences == null) return; + preferences.putString(KEY_SELECTED_ROM, id); + preferences.flush(); + } + + /** + * Stores the ROM id supplied via the page's {@code ?rom=} URL parameter, + * to be applied to the next program launched. Silently ignores ids that + * don't match any known option, so callers can safely pass unvalidated + * input straight from the URL. + */ + public static void setUrlRomParam(String id) { + if (id == null) return; + // Be forgiving about common URL typos — trim whitespace and trailing + // slashes (e.g. "?rom=oric1/#/basic" where the user put the slash + // before the # by analogy with path URLs). + id = id.trim(); + while (id.endsWith("/")) { + id = id.substring(0, id.length() - 1); + } + if (id.isEmpty()) return; + for (Option opt : OPTIONS) { + if (opt.id.equals(id)) { + urlRomParam = id; + return; + } + } + // Unknown id — ignore. + } + + /** + * Returns the pending URL {@code ?rom=} Option and clears the field, so + * the value is applied at most once. Returns null if no URL ROM parameter + * was captured, or if it has already been taken. + */ + public static Option takeUrlRomParam() { + if (urlRomParam == null) return null; + String id = urlRomParam; + urlRomParam = null; + for (Option opt : OPTIONS) { + if (opt.id.equals(id)) return opt; + } + return null; + } + + /** + * Returns the Option whose id matches the given id, or null if no such + * option exists (or id is null/empty). + */ + public static Option findById(String id) { + if (id == null || id.isEmpty()) return null; + for (Option opt : OPTIONS) { + if (opt.id.equals(id)) return opt; + } + return null; + } + + /** + * Resolves which ROM to use for launching a given program. + *
+ * There are three distinct sources, each with a clearly-defined scope: + *
+ * Local-file launches are distinguished by having a non-empty, non-http + * filePath. Everything else (curated tiles including BASIC, and URL + * launches via {@code ?url=} or a {@code #/slug} route) is treated as + * curator-or-default territory — the persisted preference is not + * consulted. + */ + public static Option resolveRom(AppConfigItem appConfigItem, Preferences preferences) { + Option urlRom = takeUrlRomParam(); + if (urlRom != null) return urlRom; + + String filePath = appConfigItem != null ? appConfigItem.getFilePath() : null; + boolean localFile = filePath != null && !filePath.isEmpty() && !filePath.startsWith("http"); + if (localFile) { + return getSelected(preferences); + } + Option curatorRom = findById(appConfigItem != null ? appConfigItem.getRom() : null); + if (curatorRom != null) return curatorRom; + return OPTIONS[0]; // Atmos default + } +} diff --git a/core/src/main/java/emu/joric/config/AppConfigItem.java b/core/src/main/java/emu/joric/config/AppConfigItem.java index f298772..e5a08b0 100644 --- a/core/src/main/java/emu/joric/config/AppConfigItem.java +++ b/core/src/main/java/emu/joric/config/AppConfigItem.java @@ -18,6 +18,15 @@ public class AppConfigItem { private String ram = "RAM_48K"; + /** + * Optional ROM id (e.g. "atmos" or "oric1") declaring which Oric ROM this + * program needs. Matches {@link emu.joric.RomConfig.Option#id}. When + * null/absent, the program launches with the Atmos default. Only consulted + * for curated launches — local-file loads use the persisted preference + * instead. + */ + private String rom; + private FileLocation fileLocation = FileLocation.INTERNAL; private String status = "WORKING"; @@ -184,4 +193,19 @@ public byte[] getFileData() { public void setFileData(byte[] fileData) { this.fileData = fileData; } + + /** + * @return the ROM id for this program, or null if none specified (falls + * back to the normal ROM resolution rules). + */ + public String getRom() { + return rom; + } + + /** + * @param rom the ROM id (matching {@link emu.joric.RomConfig.Option#id}). + */ + public void setRom(String rom) { + this.rom = rom; + } } diff --git a/core/src/main/java/emu/joric/ui/DialogHandler.java b/core/src/main/java/emu/joric/ui/DialogHandler.java index f3d7a27..8aaa399 100644 --- a/core/src/main/java/emu/joric/ui/DialogHandler.java +++ b/core/src/main/java/emu/joric/ui/DialogHandler.java @@ -36,11 +36,25 @@ public interface DialogHandler { /** * Shows the About AGILE message dialog. - * + * * @param aboutMessage The About message to display. * @param textInputResponseHandler Optional state management button response. */ public void showAboutDialog(String aboutMessage, TextInputResponseHandler textInputResponseHandler); + + /** + * Invoked when JOric wants the user to choose one option from a list. + * + * @param title The dialog title (may be "" if not needed). + * @param message A label / instruction shown above the list of options. + * @param options The available option strings. + * @param currentSelection The currently-selected option string (may be null). + * @param textInputResponseHandler The handler to be invoked with the user's + * response. On success, text is the chosen option (one of the strings + * in options). On cancel, success is false and text is null. + */ + public void promptForOption(String title, String message, String[] options, + String currentSelection, TextInputResponseHandler textInputResponseHandler); /** * Returns true if a dialog is currently open. diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/html/src/main/java/emu/joric/gwt/GwtDialogHandler.java b/html/src/main/java/emu/joric/gwt/GwtDialogHandler.java index e92e2de..327ddef 100644 --- a/html/src/main/java/emu/joric/gwt/GwtDialogHandler.java +++ b/html/src/main/java/emu/joric/gwt/GwtDialogHandler.java @@ -1,6 +1,7 @@ package emu.joric.gwt; import com.badlogic.gdx.Gdx; +import com.google.gwt.core.client.JsArrayString; import com.google.gwt.typedarrays.shared.Int8Array; import com.google.gwt.typedarrays.shared.TypedArrays; @@ -186,9 +187,40 @@ private final native void showHtmlAboutDialog(String message, TextInputResponseH }); }-*/; + @Override + public void promptForOption(final String title, final String message, final String[] options, + final String currentSelection, final TextInputResponseHandler textInputResponseHandler) { + Gdx.app.postRunnable(new Runnable() { + @Override + public void run() { + dialogOpen = true; + // JSNI can't receive a Java String[] directly; wrap the options + // into a native JS array via JsArrayString. + JsArrayString jsOptions = (JsArrayString) JsArrayString.createArray(); + for (String option : options) { + jsOptions.push(option); + } + showHtmlPromptForOption(title, message, jsOptions, currentSelection, textInputResponseHandler); + } + }); + } + + private final native void showHtmlPromptForOption(String title, String message, JsArrayString options, + String currentSelection, TextInputResponseHandler textInputResponseHandler)/*-{ + var that = this; + this.dialog.promptForOption(title, message, options, currentSelection).then(function (res) { + if (res && res.option) { + textInputResponseHandler.@emu.joric.ui.TextInputResponseHandler::inputTextResult(ZLjava/lang/String;)(true, res.option); + } else { + textInputResponseHandler.@emu.joric.ui.TextInputResponseHandler::inputTextResult(ZLjava/lang/String;)(false, null); + } + that.@emu.joric.gwt.GwtDialogHandler::dialogOpen = false; + }); + }-*/; + @Override public boolean isDialogOpen() { return dialogOpen; } - + } diff --git a/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java b/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java index dced4ce..199cf1c 100644 --- a/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java +++ b/html/src/main/java/emu/joric/gwt/GwtJOricRunner.java @@ -14,6 +14,7 @@ import emu.joric.KeyboardMatrix; import emu.joric.PixelData; import emu.joric.Program; +import emu.joric.RomConfig; import emu.joric.config.AppConfigItem; import emu.joric.worker.MessageEvent; import emu.joric.worker.MessageHandler; @@ -44,10 +45,33 @@ public class GwtJOricRunner extends JOricRunner { */ public GwtJOricRunner(KeyboardMatrix keyboardMatrix, PixelData pixelData) { super(keyboardMatrix, pixelData, null); - + psg = new GwtAYPSG(this); - + registerPopStateEventHandler(); + captureRomParamFromUrl(); + } + + /** + * If the page was loaded with a ?rom= URL parameter AND the URL is also + * directly loading a specific program (via ?url= or a hash route like + * #/basic), store the requested ROM id for the program about to + * be launched. The parameter is ignored when the URL has no direct-load + * indicator (e.g. the plain home screen). + */ + private void captureRomParamFromUrl() { + String romParam = Window.Location.getParameter("rom"); + if (romParam == null || romParam.isEmpty()) { + return; + } + String urlParam = Window.Location.getParameter("url"); + String hash = Window.Location.getHash(); + boolean directLoad = + (urlParam != null && !urlParam.isEmpty()) || + (hash != null && !hash.trim().isEmpty()); + if (directLoad) { + RomConfig.setUrlRomParam(romParam); + } } private native void registerPopStateEventHandler() /*-{ @@ -80,30 +104,30 @@ private void onPopState(Event e) { @Override public void start(AppConfigItem appConfigItem) { // Do not change the URL if joric was invoked with "url" request param. - if ((Window.Location.getParameter("url") == null) && + if ((Window.Location.getParameter("url") == null) && (!"Adhoc Oric Program".equals(appConfigItem.getName()))) { - // The URL Builder doesn't add a / before the #, so we do this ourselves. + // setPath normalises the URL to end with a '/' in cases that need them + // such as a bare "http://host". The '#' fragment can be appended + // directly without any extra '/'s to give a well formed URL + // (including for example in the case where a query string is present). String newURL = Window.Location.createUrlBuilder().setPath("/").setHash(null).buildString(); - if (newURL.endsWith("/")) { - newURL += "#/"; - } else { - newURL += "/#/"; - } - newURL += slugify(appConfigItem.getName()); - + newURL += "#/" + slugify(appConfigItem.getName()); + updateURLWithoutReloading(newURL); } - + GwtProgramLoader programLoader = new GwtProgramLoader(); programLoader.fetchProgram(appConfigItem, p -> createWorker(appConfigItem, p)); } - private ArrayBuffer convertProgramToArrayBuffer(Program program) { + private ArrayBuffer convertProgramToArrayBuffer(AppConfigItem appConfigItem, Program program) { int programDataLength = (program != null? program.getProgramData().length : 0); ArrayBuffer programArrayBuffer = TypedArrays.createArrayBuffer(programDataLength + 16384 + 8192); Uint8Array programUint8Array = TypedArrays.createUint8Array(programArrayBuffer); int index = 0; - byte[] basicRom = Gdx.files.internal("roms/basic11b.rom").readBytes(); + RomConfig.Option romOpt = RomConfig.resolveRom( + appConfigItem, Gdx.app.getPreferences("joric.preferences")); + byte[] basicRom = Gdx.files.internal("roms/" + romOpt.filename).readBytes(); for (int i=0; i < basicRom.length; index++, i++) { programUint8Array.set(index, (basicRom[i] & 0xFF)); } @@ -126,7 +150,7 @@ private ArrayBuffer convertProgramToArrayBuffer(Program program) { */ public void createWorker(AppConfigItem appConfigItem, Program program) { // Convert program bytes to ArrayBuffer. - ArrayBuffer programArrayBuffer = convertProgramToArrayBuffer(program); + ArrayBuffer programArrayBuffer = convertProgramToArrayBuffer(appConfigItem, program); worker = Worker.create("/worker/worker.nocache.js"); @@ -260,6 +284,7 @@ private void clearUrl() { .setPath("/") .setHash(null) .removeParameter("url") + .removeParameter("rom") .buildString(); updateURLWithoutReloading(newURL); } diff --git a/html/webapp/dialog.js b/html/webapp/dialog.js index 7490519..8efddeb 100644 --- a/html/webapp/dialog.js +++ b/html/webapp/dialog.js @@ -188,11 +188,12 @@ class Dialog { return this.waitForUser(); } - promptForOption(title, message, options) { + promptForOption(title, message, options, selectedOption) { let template = ``; template += ''; const settings = Object.assign({}, { message: title, template: template}); diff --git a/html/webapp/styles.css b/html/webapp/styles.css index f7fc567..7ce6d2e 100644 --- a/html/webapp/styles.css +++ b/html/webapp/styles.css @@ -175,7 +175,12 @@ a { :where([data-component*="dialog"] [data-ref="message"]) { font-size: var(--dlg-message-fz, 1.25em); + font-weight: bold; margin-block-end: var(--dlg-gap); + /*