Dashboard Studio 1.14.1: Android app, phone menu button, install fix

- Android app (android/, built with build.sh): signs in to Home Assistant once, opens Dashboard Studio full screen, renews the ingress session; downloadable from Install as an app
- Phone dashboards: a round menu button brings back the top bar with Edit, Kiosk and Install as an app
- Install as an app explains what works per device (Android app, https for browser install, iPhone limits)
- Fixed: browser install failed because the manifest was fetched without the Home Assistant sign-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 22:34:52 +01:00
parent 79d4a0f011
commit 297fb2a7a0
24 changed files with 561 additions and 26 deletions
+1
View File
@@ -0,0 +1 @@
build/
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="nl.melvanveen.dashboardstudio"
android:versionCode="1"
android:versionName="1.14.1">
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="34" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.VIBRATE" />
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:allowBackup="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|uiMode|density"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- "Open in the app" in Dashboard Studio: dashboardstudio://open?url=<the app's address> -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="dashboardstudio" android:host="open" />
</intent-filter>
</activity>
</application>
</manifest>
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+63
View File
@@ -0,0 +1,63 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { color-scheme: dark; }
body { margin: 0; min-height: 100vh; background: #0f1526; color: #f2f2f7; font: 16px/1.45 system-ui, sans-serif; display: flex; align-items: center; justify-content: center; }
main { width: 100%; max-width: 420px; padding: 28px 22px; box-sizing: border-box; }
img { width: 72px; height: 72px; border-radius: 18px; display: block; margin-bottom: 18px; }
h1 { font-size: 24px; margin: 0 0 8px; }
p { margin: 0 0 18px; color: #a1a8bb; }
.msg { background: rgba(255, 69, 58, .15); color: #ffb4ae; padding: 10px 14px; border-radius: 12px; }
.msg:empty { display: none; }
label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
input { width: 100%; box-sizing: border-box; font: inherit; color: inherit; background: #1c2438; border: 1px solid #2f3a57; border-radius: 12px; padding: 12px 14px; }
input:focus { outline: 2px solid #0a84ff; border-color: transparent; }
.hint { font-size: 13px; margin: 8px 0 22px; }
button { width: 100%; font: inherit; font-weight: 600; color: #fff; background: #0a84ff; border: 0; border-radius: 12px; padding: 13px; }
button:active { opacity: .8; }
</style>
</head>
<body>
<main>
<img src="icon.png" alt="">
<h1 data-t="title">Connect to Home Assistant</h1>
<p data-t="intro">Enter the address you use to open Home Assistant. You sign in once; the app remembers it.</p>
<p class="msg" id="msg"></p>
<form id="f">
<label for="addr" data-t="label">Home Assistant address</label>
<input id="addr" type="text" inputmode="url" autocomplete="url" autocapitalize="off" spellcheck="false" placeholder="http://homeassistant.local:8123" required>
<p class="hint" data-t="hint">You can also paste the link from Dashboard Studio: Install as an app, Open in the app.</p>
<button type="submit" data-t="go">Connect</button>
</form>
</main>
<script>
const NL = {
title: "Verbinden met Home Assistant",
intro: "Vul het adres in waarmee je Home Assistant opent. Je logt één keer in; de app onthoudt het.",
label: "Adres van Home Assistant",
hint: "Je kunt ook de link uit Dashboard Studio plakken: Installeren als app, Openen in de app.",
go: "Verbinden",
offline: "Home Assistant is niet bereikbaar op dit adres. Controleer het adres en je verbinding.",
notfound: "Dashboard Studio staat niet in Home Assistant. Installeer de app daar eerst, of plak de link uit Dashboard Studio.",
noaddress: "Dashboard Studio is gevonden, maar alleen een beheerder kan het adres opvragen. Plak de link uit Dashboard Studio: Installeren als app, Openen in de app.",
error: "Verbinden lukte niet: {0}",
};
const EN = {
offline: "Home Assistant can't be reached at this address. Check the address and your connection.",
notfound: "Dashboard Studio isn't installed in Home Assistant. Install it there first, or paste the link from Dashboard Studio.",
noaddress: "Dashboard Studio was found, but only an administrator can look up its address. Paste the link from Dashboard Studio: Install as an app, Open in the app.",
error: "Couldn't connect: {0}",
};
const T = navigator.language.startsWith("nl") ? NL : EN;
if (T === NL) document.querySelectorAll("[data-t]").forEach((el) => { el.textContent = NL[el.dataset.t] || el.textContent; });
const why = window.DSApp ? DSApp.message() : "", detail = window.DSApp ? DSApp.detail() : "";
if (why) document.getElementById("msg").textContent = (T[why] || T.error).replace("{0}", detail || why);
const addr = document.getElementById("addr");
addr.value = window.DSApp ? DSApp.address() : "";
document.getElementById("f").onsubmit = (e) => { e.preventDefault(); DSApp.setup(addr.value.trim()); };
</script>
</body>
</html>
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
# Builds the Android app without Gradle, with the Android SDK's own tools, and copies it into the
# Home Assistant app, which offers it for download (Install as an app on an Android phone).
# ./build.sh the release build, signed with ~/.android/dashboard-studio.jks (made the first time)
# ./build.sh debug a debuggable build, for testing in the emulator (Chrome can inspect its pages)
# Keep the key: Android only installs an update that is signed with the same key as the installed app.
set -e
cd "$(dirname "$0")"
SDK="${ANDROID_HOME:-$HOME/Library/Android/sdk}"
BT="$SDK/build-tools/$(ls "$SDK/build-tools" | sort -V | tail -1)"
JAR="$SDK/platforms/$(ls "$SDK/platforms" | sort -V | tail -1)/android.jar"
KEY="${DS_KEYSTORE:-$HOME/.android/dashboard-studio.jks}"
OUT=build
rm -rf "$OUT" && mkdir -p "$OUT/res" "$OUT/classes" "$OUT/gen"
DEBUG=""
[ "$1" = "debug" ] && DEBUG="--debug-mode"
"$BT/aapt2" compile --dir res -o "$OUT/res"
"$BT/aapt2" link -o "$OUT/app.unsigned.apk" -I "$JAR" --manifest AndroidManifest.xml -A assets \
--java "$OUT/gen" $DEBUG "$OUT"/res/*.flat
javac -nowarn --release 17 -classpath "$JAR" -d "$OUT/classes" $(find src "$OUT/gen" -name '*.java')
"$BT/d8" --release --min-api 24 --lib "$JAR" --output "$OUT" $(find "$OUT/classes" -name '*.class')
(cd "$OUT" && zip -q app.unsigned.apk classes.dex)
"$BT/zipalign" -f -p 4 "$OUT/app.unsigned.apk" "$OUT/app.aligned.apk"
if [ ! -f "$KEY" ]; then
mkdir -p "$(dirname "$KEY")"
keytool -genkeypair -keystore "$KEY" -storepass dashboard-studio -keypass dashboard-studio -alias app \
-keyalg RSA -keysize 4096 -validity 36500 -dname "CN=Dashboard Studio" >/dev/null
echo "Made a new signing key: $KEY (keep it, updates need the same key)"
fi
"$BT/apksigner" sign --ks "$KEY" --ks-pass pass:dashboard-studio --ks-key-alias app --out "$OUT/dashboard-studio.apk" "$OUT/app.aligned.apk"
if [ -z "$DEBUG" ]; then
cp "$OUT/dashboard-studio.apk" ../dashboard_studio/web/dashboard-studio.apk
echo "Built and copied to dashboard_studio/web/dashboard-studio.apk"
else
echo "Built $OUT/dashboard-studio.apk (debug)"
fi
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<gradient android:angle="270" android:startColor="#24304f" android:endColor="#0f1526" />
</shape>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_bg" />
<foreground android:drawable="@drawable/ic_fg" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Dashboards</string>
<style name="AppTheme" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/bg</item>
<item name="android:statusBarColor">@color/bg</item>
<item name="android:navigationBarColor">@color/bg</item>
<item name="android:colorAccent">#0a84ff</item>
</style>
<color name="bg">#0f1526</color>
</resources>
@@ -0,0 +1,299 @@
package nl.melvanveen.dashboardstudio;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.ApplicationInfo;
import android.graphics.Color;
import android.net.Uri;
import android.os.Bundle;
import android.view.View;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.JavascriptInterface;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
/**
* Dashboard Studio as an Android app: a full-screen window on the Dashboard Studio app inside Home Assistant.
*
* Home Assistant only lets a signed-in browser into an app's page (its "ingress"). So the app first opens
* Home Assistant itself, where you sign in once; then, on Home Assistant's page, it asks for an ingress
* session the way Home Assistant's own page does, and opens Dashboard Studio. From then on Dashboard Studio
* renews that session itself, with the login Home Assistant kept in this app.
*/
public class MainActivity extends Activity {
private static final String SETUP = "file:///android_asset/setup.html";
private static final int PICK_FILE = 1;
private WebView web;
private SharedPreferences prefs;
private String message = "", detail = "";
private long connectedAt = 0;
private ValueCallback<Uri[]> fileCallback;
/** Runs on Home Assistant's own page: wait until it's signed in, open an ingress session, then go to Dashboard Studio. */
private static final String CONNECT_JS = """
(function () {
if (window.__dsConnect) return;
window.__dsConnect = true;
const known = DSApp.appUrl();
let tries = 0;
const tick = async () => {
const hass = document.querySelector("home-assistant")?.hass;
if (!hass?.connected || !hass.panels || !Object.keys(hass.panels).length) {
if (++tries < 1200) setTimeout(tick, 250);
return;
}
try {
// keep the login (as "Keep me logged in" does), so Dashboard Studio can renew its session later
if (!localStorage.getItem("hassTokens") && hass.auth?.data) localStorage.setItem("hassTokens", JSON.stringify(hass.auth.data));
let url = known;
if (!url) {
const panel = Object.values(hass.panels).find((p) => /(^|_)dashboard_studio$/.test(p.config?.ingress || ""));
if (!panel) return DSApp.fail("notfound", "");
let info;
try { info = await hass.callWS({ type: "supervisor/api", endpoint: `/addons/${panel.config.ingress}/info`, method: "get" }); }
catch { info = (await hass.callApi("GET", `hassio/addons/${panel.config.ingress}/info`).catch(() => null))?.data; }
if (!info?.ingress_url) return DSApp.fail("noaddress", "");
url = location.origin + info.ingress_url;
}
const res = await hass.callApi("POST", "hassio/ingress/session");
const session = res?.data?.session || res?.session;
document.cookie = `ingress_session=${session};path=/api/hassio_ingress/;SameSite=Strict${location.protocol === "https:" ? ";Secure" : ""}`;
DSApp.open(url);
} catch (e) {
DSApp.fail("error", String(e?.message || e?.body?.message || e?.code || e));
}
};
tick();
})();
""";
@Override
protected void onCreate(Bundle state) {
super.onCreate(state);
prefs = getSharedPreferences("app", MODE_PRIVATE);
if ((getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0) WebView.setWebContentsDebuggingEnabled(true);
web = new WebView(this);
web.setBackgroundColor(Color.parseColor("#0f1526"));
setContentView(web);
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true);
s.setDatabaseEnabled(true);
s.setMediaPlaybackRequiresUserGesture(false); // camera streams and the doorbell sound
s.setMixedContentMode(WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE);
s.setUserAgentString(s.getUserAgentString() + " DashboardStudioApp/1.0");
CookieManager.getInstance().setAcceptCookie(true);
web.addJavascriptInterface(new Bridge(), "DSApp");
web.setWebViewClient(new Client());
web.setWebChromeClient(new Chrome());
if (!fromLink(getIntent()) && state == null) start();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
fromLink(intent);
}
/** "Open in the app" in Dashboard Studio: dashboardstudio://open?url=... */
private boolean fromLink(Intent intent) {
Uri data = intent == null ? null : intent.getData();
if (data == null || !"dashboardstudio".equals(data.getScheme())) return false;
String url = data.getQueryParameter("url");
if (url == null || !url.matches("(?i)https?://.+")) return false;
save(origin(Uri.parse(url)), url);
start();
return true;
}
private String ha() { return prefs.getString("ha", ""); }
private String app() { return prefs.getString("app", ""); }
private void save(String ha, String app) { prefs.edit().putString("ha", ha).putString("app", app).apply(); }
private static String origin(Uri u) {
return u.getScheme() + "://" + u.getHost() + (u.getPort() > 0 ? ":" + u.getPort() : "");
}
private static boolean isIngress(String url) { return url != null && url.contains("/api/hassio_ingress/"); }
private void start() {
message = ""; detail = "";
if (ha().isEmpty()) { web.loadUrl(SETUP); return; }
// With the address known, try it straight away: a session may still be valid, or the page is cached for offline use.
web.loadUrl(app().isEmpty() ? ha() + "/" : app());
}
private void connect() {
connectedAt = System.currentTimeMillis();
web.loadUrl(ha() + "/");
}
private void showSetup(String why, String more) {
message = why; detail = more == null ? "" : more;
web.loadUrl(SETUP);
}
private class Client extends WebViewClient {
@Override
public void onPageFinished(WebView view, String url) {
if (url == null || ha().isEmpty() || !url.startsWith(ha())) return;
String page = app().replaceAll("[?#].*", "");
if (isIngress(url) || (!page.isEmpty() && url.startsWith(page))) view.clearHistory(); // back leaves the app instead of going to Home Assistant
else view.evaluateJavascript(CONNECT_JS, null);
}
@Override
public void onReceivedHttpError(WebView view, WebResourceRequest req, WebResourceResponse res) {
if (!req.isForMainFrame() || !isIngress(req.getUrl().toString())) return;
int code = res.getStatusCode();
if (code != 401 && code != 403) return;
// The ingress session ran out: get a new one from Home Assistant's page, unless that just failed.
if (System.currentTimeMillis() - connectedAt < 20000) showSetup("error", "Home Assistant refused the session (" + code + ")");
else connect();
}
@Override
public void onReceivedError(WebView view, WebResourceRequest req, WebResourceError err) {
if (req.isForMainFrame() && !req.getUrl().toString().startsWith("file:")) showSetup("offline", "");
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest req) {
Uri u = req.getUrl();
String scheme = u.getScheme() == null ? "" : u.getScheme();
if ((scheme.equals("http") || scheme.equals("https")) && !ha().isEmpty() && origin(u).equalsIgnoreCase(ha())) return false;
if (scheme.equals("file")) return false;
// other sites and apps: the browser or the app they belong to
try {
Intent i = scheme.equals("intent") ? Intent.parseUri(u.toString(), Intent.URI_INTENT_SCHEME) : new Intent(Intent.ACTION_VIEW, u);
i.addCategory(Intent.CATEGORY_BROWSABLE);
i.setComponent(null);
startActivity(i);
} catch (Exception ignored) { /* nothing to open it with */ }
return true;
}
}
private class Chrome extends WebChromeClient {
@Override
public boolean onShowFileChooser(WebView view, ValueCallback<Uri[]> callback, FileChooserParams params) {
if (fileCallback != null) fileCallback.onReceiveValue(null);
fileCallback = callback;
try {
startActivityForResult(params.createIntent(), PICK_FILE);
} catch (ActivityNotFoundException e) {
fileCallback = null;
return false;
}
return true;
}
}
@Override
protected void onActivityResult(int request, int result, Intent data) {
if (request == PICK_FILE && fileCallback != null) {
fileCallback.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(result, data));
fileCallback = null;
} else super.onActivityResult(request, result, data);
}
@Override
public void onBackPressed() {
if (web.canGoBack()) web.goBack();
else super.onBackPressed();
}
@Override
protected void onPause() { super.onPause(); web.onPause(); CookieManager.getInstance().flush(); }
@Override
protected void onResume() { super.onResume(); web.onResume(); }
@Override
protected void onSaveInstanceState(Bundle out) { super.onSaveInstanceState(out); web.saveState(out); }
@Override
protected void onRestoreInstanceState(Bundle state) {
super.onRestoreInstanceState(state);
if (web.restoreState(state) == null) start();
}
/** What the pages can call, as window.DSApp. */
private class Bridge {
@JavascriptInterface public String address() { return ha(); }
@JavascriptInterface public String appUrl() { return app(); }
@JavascriptInterface public String message() { return message; }
@JavascriptInterface public String detail() { return detail; }
/** The setup page: a Home Assistant address, or the link from Dashboard Studio. */
@JavascriptInterface public void setup(String input) {
runOnUiThread(() -> {
String text = input == null ? "" : input.trim();
if (text.isEmpty()) return;
if (!text.matches("(?i)https?://.*")) text = "http://" + text;
Uri u = Uri.parse(text);
if (u.getHost() == null || u.getHost().isEmpty()) { showSetup("offline", ""); return; }
save(origin(u), isIngress(text) ? text : "");
start();
});
}
@JavascriptInterface public void open(String url) {
runOnUiThread(() -> {
if (url == null || !url.startsWith(ha())) return;
save(ha(), url);
web.loadUrl(url);
});
}
@JavascriptInterface public void fail(String why, String more) { runOnUiThread(() -> showSetup(why, more)); }
/** Dashboard Studio: Install as an app, Change address. */
@JavascriptInterface public void reset() { runOnUiThread(() -> { message = ""; detail = ""; web.loadUrl(SETUP); }); }
/** Kiosk mode: keep the screen on and hide the system bars. */
@JavascriptInterface public void keepAwake(boolean on) {
runOnUiThread(() -> {
View decor = getWindow().getDecorView();
if (on) {
getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
} else {
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
decor.setSystemUiVisibility(0);
}
});
}
/** The status and navigation bars take the dashboard's background color. */
@JavascriptInterface public void setBarColor(String css) {
runOnUiThread(() -> {
try {
String c = css.trim();
if (c.matches("#[0-9a-fA-F]{3}")) c = "#" + c.charAt(1) + c.charAt(1) + c.charAt(2) + c.charAt(2) + c.charAt(3) + c.charAt(3);
if (c.matches("#[0-9a-fA-F]{8}")) c = "#" + c.substring(7) + c.substring(1, 7); // CSS #rrggbbaa, Android #aarrggbb
int color = Color.parseColor(c) | 0xff000000;
getWindow().setStatusBarColor(color);
getWindow().setNavigationBarColor(color);
double light = 0.299 * Color.red(color) + 0.587 * Color.green(color) + 0.114 * Color.blue(color);
View decor = getWindow().getDecorView();
int flags = decor.getSystemUiVisibility() & ~(View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR);
if (light > 150) flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR;
decor.setSystemUiVisibility(flags);
} catch (IllegalArgumentException ignored) { /* not a color Android knows */ }
});
}
}
}
+7
View File
@@ -1,5 +1,12 @@
# Changelog # Changelog
## 1.14.1
- **Android app:** Dashboard Studio as a real app for Android phones and tablets. Download it from **Install as an app**, sign in to Home Assistant once, and it opens full screen on your dashboard from then on, with its own icon. It works with plain http addresses too.
- **Phone dashboards:** a round **⋯** button in the corner brings back the top bar with **Edit**, **Kiosk** and **Install as an app**, so you no longer have to find an empty spot to touch and hold.
- **Install as an app** now says what works on your device: the app on Android, https for installing from the browser, and why it can't work from the home screen on iPhone.
- Fixed: installing from the browser could fail with "can't be installed", because the browser fetched the app's details without the Home Assistant sign-in.
## 1.14.0 ## 1.14.0
- **Seven new cards:** parcels on the way (PostNL, DHL), air quality per room with a hint to open a window, water and gas use per day, the solar forecast with the best time to run the washer, a garage and gate button that asks first, sports scores (TeamTracker, optionally only on match days) and Formula One (next race, sessions, standings and results). - **Seven new cards:** parcels on the way (PostNL, DHL), air quality per room with a hint to open a window, water and gas use per day, the solar forecast with the best time to run the washer, a garage and gate button that asks first, sports scores (TeamTracker, optionally only on match days) and Formula One (next race, sessions, standings and results).
+6 -3
View File
@@ -214,12 +214,15 @@ In edit mode, hidden widgets stay visible but faded, with a **Hidden now** label
Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns. Turn on **Designed for phones** under **Grid** in the dashboard settings. The dashboard switches to a 4-column layout that you design at phone width, so what you see while editing is what the phone shows. Existing widgets are converted: small tiles become half width, larger cards full width, and nothing overlaps. Turning it off converts back to 12 columns.
On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, touch and hold an empty spot for a moment; it hides again after 10 seconds. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other. On a phone, such a dashboard fills the whole screen, without the top bar with the dashboard picker and **Edit**. To bring the bar back, tap the round **⋯** button in the top right corner (or touch and hold an empty spot); it hides again after 15 seconds or when you tap **⋯** again. On a computer, a phone dashboard shows as a phone-width column. Dashboards that aren't designed for phones keep reflowing into two columns on phones, with the top bar. With the phone on its side, a phone dashboard shows two columns of its layout next to each other.
### On phones ### On phones
- **Install as an app:** **⋯ → Install as an app…** explains how. Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it). Installed, it opens full screen with its own icon and the dashboard's colors in the status bar. Dashboard Studio keeps Home Assistant's sign-in fresh in the background while it's open, using the login Home Assistant saved in that browser (with *Keep me logged in*). If it was closed for a long time and shows an error, open Home Assistant once. - **Install as an app:** tap the phone button in the top bar (or **⋯ → Install as an app…** in edit mode). What it offers depends on the phone:
- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. On Android, long-press the installed app's icon to run it straight away (up to four shortcuts; iPhones don't support this). - **Android:** the Dashboard Studio app. Tap **Download the app**, open the downloaded file and allow installing apps from that source when Android asks (once). Then come back and tap **Open in the app**, or open the app and enter the address you use for Home Assistant. Sign in to Home Assistant once in the app; after that it opens straight on Dashboard Studio, full screen with its own icon and the dashboard's colors in the status bar, and keeps you signed in. Kiosk mode keeps the screen on and hides Android's bars. To connect to another Home Assistant, choose **Install as an app → Change address** in the app. The app finds Dashboard Studio by itself if you sign in as an administrator; other people can tap **Open in the app** on their phone instead.
- **From the browser** (Android and computers): the browser can also install Dashboard Studio as a web app, but only when Home Assistant is opened over **https** (for example Home Assistant Cloud or your own certificate). Home Assistant's own page can't be installed, so open Dashboard Studio in its own tab first (the dialog has a button for it).
- **iPhone and iPad:** apps on the home screen get their own storage there, without Home Assistant's sign-in, so they can't open an app that runs inside Home Assistant. Use Dashboard Studio from the sidebar in the Home Assistant app.
- **Shortcuts:** tick **Shortcut on the app icon** in the settings of a button, tile, light, thermostat or garage card. When Dashboard Studio is installed from the browser on Android, long-press its icon to run one straight away (up to four shortcuts; the Android app and iPhones don't support this).
- **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects. - **Offline:** each device keeps the last known states of its dashboard. When the connection drops, the dashboard keeps showing them with a small note saying from when, and catches up when it reconnects.
- **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves). - **Swipe** left or right over the dashboard to go to the next or previous tab (not over cards that swipe or scroll themselves).
- **Pull down** at the top of the dashboard to reload every state. - **Pull down** at the top of the dashboard to reload every state.
+1 -1
View File
@@ -1,5 +1,5 @@
name: Dashboard Studio name: Dashboard Studio
version: "1.14.0" version: "1.14.1"
slug: dashboard_studio slug: dashboard_studio
description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes. description: Drag-and-drop dashboard builder with a free-form grid, live entity widgets and themes.
url: https://git.melvanveen.nl/veenm/ha-dashboard-studio url: https://git.melvanveen.nl/veenm/ha-dashboard-studio
+8
View File
@@ -234,6 +234,13 @@ async def service_worker(request):
return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"}) return web.Response(text=js, content_type="application/javascript", headers={"Cache-Control": "no-store"})
async def android_app(request):
"""The Android app (built from android/ in the repository), to download on a phone."""
return web.FileResponse(WEB_DIR / "dashboard-studio.apk", headers={
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": 'attachment; filename="Dashboard Studio.apk"', "Cache-Control": "no-store"})
async def get_dashboards(request): async def get_dashboards(request):
return web.json_response(load_store()) return web.json_response(load_store())
@@ -372,6 +379,7 @@ def make_app():
app.router.add_get("/api/version", version) app.router.add_get("/api/version", version)
app.router.add_get("/manifest.webmanifest", manifest) app.router.add_get("/manifest.webmanifest", manifest)
app.router.add_get("/sw.js", service_worker) app.router.add_get("/sw.js", service_worker)
app.router.add_get("/android.apk", android_app)
app.router.add_get("/api/changelog", changelog) app.router.add_get("/api/changelog", changelog)
app.router.add_get("/api/whoami", whoami) app.router.add_get("/api/whoami", whoami)
app.router.add_get("/api/users", users) app.router.add_get("/api/users", users)
+56 -20
View File
@@ -246,21 +246,50 @@
let installPrompt = null; let installPrompt = null;
window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; }); window.addEventListener("beforeinstallprompt", (e) => { e.preventDefault(); installPrompt = e; });
const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true; const standalone = () => matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
// The Android app (android/ in the repository) shows this page in its own window and adds window.DSApp.
const IN_APP = !!window.DSApp;
const APK_ID = "nl.melvanveen.dashboardstudio";
async function installDialog() { async function installDialog() {
let inFrame = false; let inFrame = false;
try { inFrame = window.top !== window; } catch { inFrame = true; } try { inFrame = window.top !== window; } catch { inFrame = true; }
const ios = /iphone|ipad|ipod/i.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1); const ua = navigator.userAgent;
const how = inFrame const ios = /iphone|ipad|ipod/i.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
? tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there.") const android = /android/i.test(ua);
: ios ? tr("In Safari, tap the Share button, then Add to Home Screen.") const link = new URL(`?ds=${encodeURIComponent(current.id)}`, BASE).href;
: installPrompt ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") const browserInstall = !inFrame && window.isSecureContext && installPrompt && !standalone();
: tr("In your browser’s menu, choose Install app or Add to Home screen."); const p = (t, cls = "") => `<p${cls ? ` class="${cls}"` : ""}>${esc(t)}</p>`;
const choice = await openModal(`<h3>${tr("Install as an app")}</h3><p>${esc(how)}</p> let body, actions = `<button class="btn" data-x="cancel">${tr("Close")}</button>`;
<p class="muted small">${tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon.")}</p> if (IN_APP) {
<div class="modal-actions"><button class="btn" data-x="cancel">${tr("Close")}</button>${inFrame ? `<button class="btn primary" data-x="tab">${tr("Open in its own tab")}</button>` : !ios && installPrompt && !standalone() ? `<button class="btn primary" data-x="install">${tr("Install")}</button>` : ""}</div>`, body = p(tr("This is the Dashboard Studio app, connected to {0}.", location.host));
(e, close) => { const x = e.target.closest("[data-x]")?.dataset.x; if (x) close(x); }); actions += `<button class="btn" data-x="reset">${tr("Change address")}</button>`;
if (choice === "tab") window.open(new URL(`?ds=${current.id}`, BASE).href, "_blank", "noopener"); } else if (android) {
// A home screen app from the browser needs https, and Home Assistant's own app can't make one at all: the Android app always works.
body = p(tr("Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in."))
+ `<ol class="steps"><li>${esc(tr("Download the app and open the file to install it. Android asks once to allow apps from this source."))}</li>
<li>${esc(tr("Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant."))}</li></ol>`
+ (browserInstall ? p(tr("Or install it from the browser, without the app."), "muted small") : "");
actions += (browserInstall ? `<button class="btn" data-x="install">${tr("Install from the browser")}</button>` : "")
+ `<a class="btn" href="${new URL("android.apk", BASE).href}" download="Dashboard Studio.apk">${tr("Download the app")}</a>`
+ `<a class="btn primary" target="_top" href="intent://open?url=${encodeURIComponent(link)}#Intent;scheme=dashboardstudio;package=${APK_ID};end">${tr("Open in the app")}</a>`;
} else if (ios) {
// Home screen web apps on iPhone get their own storage, without Home Assistant's sign-in: they can't open an app inside Home Assistant.
body = p(tr("On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead."));
} else if (inFrame) {
body = p(tr("Home Assistant’s page can’t be installed itself. Open Dashboard Studio in its own tab first, then choose Install as an app again there."));
actions += `<button class="btn primary" data-x="tab">${tr("Open in its own tab")}</button>`;
} else if (!window.isSecureContext) {
body = p(tr("Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again."));
} else {
body = p(browserInstall ? tr("Your browser can install Dashboard Studio as an app with its own icon. It opens full screen, without the browser’s bars.") : tr("In your browser’s menu, choose Install app or Add to Home screen."));
if (browserInstall) actions += `<button class="btn primary" data-x="install">${tr("Install")}</button>`;
}
const choice = await openModal(`<h3>${tr("Install as an app")}</h3>${body}
${IN_APP || ios ? "" : p(tr("It opens on the dashboard chosen for that device. Mark buttons as a shortcut to use them from the app icon."), "muted small")}
<div class="modal-actions">${actions}</div>`,
(e, close) => { const x = e.target.closest("[data-x], a.btn"); if (x) close(x.dataset.x || "link"); });
if (choice === "tab") window.open(link, "_blank", "noopener");
if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; } if (choice === "install" && installPrompt) { installPrompt.prompt(); installPrompt = null; }
if (choice === "reset") window.DSApp.reset();
} }
/** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so /** Opened on its own (as an app, or in a tab), nobody renews Home Assistant's ingress session, so
@@ -770,7 +799,9 @@
const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)"; const bg = t.preset !== "System" && t.bg ? t.bg : "var(--canvas)";
if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`); if (t.bgImage) s.setProperty("--d-bg", `center / cover no-repeat url("${t.bgImage.replace(/"/g, "")}"), ${bg}`);
s.setProperty("--d-radius", `${t.radius ?? 20}px`); s.setProperty("--d-radius", `${t.radius ?? 20}px`);
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7"); const bar = /^#[0-9a-f]{3,8}$/i.test(t.bg || "") && t.preset !== "System" ? t.bg : prefersDark() ? "#000000" : "#f2f2f7";
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", bar);
window.DSApp?.setBarColor(bar);
s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`); s.setProperty("--d-gap", `${Number(current.grid.gap) || 14}px`);
stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System"); stageEl.classList.toggle("theme-outline", !!t.outline && t.preset !== "System");
s.setProperty("--d-blur", `${t.blur || 0}px`); s.setProperty("--d-blur", `${t.blur || 0}px`);
@@ -2450,6 +2481,7 @@
async function requestWakeLock() { async function requestWakeLock() {
try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; } try { wakeLock = await navigator.wakeLock?.request("screen"); } catch { wakeLock = null; }
window.DSApp?.keepAwake(true); // the Android app has no wake lock: it keeps the screen on itself
} }
const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " "); const cornerName = () => (current.kiosk.exitCorner || "top-right").replace("-", " ");
@@ -2474,6 +2506,7 @@
closeDoorbell(); closeDoorbell();
clearTimeout(idleTimer); clearTimeout(saverTimer); clearTimeout(idleTimer); clearTimeout(saverTimer);
wakeLock?.release?.(); wakeLock = null; wakeLock?.release?.(); wakeLock = null;
window.DSApp?.keepAwake(false);
if (document.fullscreenElement) document.exitFullscreen?.(); if (document.fullscreenElement) document.exitFullscreen?.();
history.replaceState(null, "", location.pathname); history.replaceState(null, "", location.pathname);
renderGrid(); renderGrid();
@@ -2880,18 +2913,18 @@
canvasEl.addEventListener("pointerdown", (e) => { canvasEl.addEventListener("pointerdown", (e) => {
if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return; if (!document.body.classList.contains("phone-ui") || e.target.closest(".w")) return;
holdTimer2 = setTimeout(() => { holdTimer2 = setTimeout(() => {
document.body.classList.add("peek"); peek(true);
clearTimeout(peekTimer);
peekTimer = setTimeout(() => document.body.classList.remove("peek"), 10000);
navigator.vibrate?.(10); navigator.vibrate?.(10);
}, 600); }, 600);
}); });
for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); }); for (const ev of ["pointerup", "pointercancel", "pointermove"]) canvasEl.addEventListener(ev, (e) => { if (ev !== "pointermove" || e.movementX || e.movementY) clearTimeout(holdTimer2); });
function phoneHint() { // The round button in the corner does the same, for dashboards without an empty spot.
if (!document.body.classList.contains("phone-ui") || lsGet("ds.peekHint")) return; function peek(on) {
lsSet("ds.peekHint", "1"); document.body.classList.toggle("peek", on);
toast(tr("Touch and hold an empty spot to show the menu.")); clearTimeout(peekTimer);
if (on) peekTimer = setTimeout(() => document.body.classList.remove("peek"), 15000);
} }
$("#phone-menu").onclick = () => peek(!document.body.classList.contains("peek"));
// ------------------------------------------------------------ chrome wiring // ------------------------------------------------------------ chrome wiring
function setCurrent(id) { function setCurrent(id) {
@@ -2954,6 +2987,8 @@
$("#btn-edit").onclick = () => setEdit(!editMode); $("#btn-edit").onclick = () => setEdit(!editMode);
$("#btn-kiosk").onclick = () => enterKiosk(); $("#btn-kiosk").onclick = () => enterKiosk();
$("#btn-app").onclick = () => installDialog();
$("#btn-app").hidden = IN_APP || standalone();
$("#btn-undo").onclick = undo; $("#btn-undo").onclick = undo;
$("#btn-redo").onclick = redo; $("#btn-redo").onclick = redo;
$("#dash-select").onchange = (e) => setCurrent(e.target.value); $("#dash-select").onchange = (e) => setCurrent(e.target.value);
@@ -3200,6 +3235,8 @@
$("#btn-redo").innerHTML = Icons.svg("redo"); $("#btn-redo").innerHTML = Icons.svg("redo");
$("#btn-menu").innerHTML = Icons.svg("more"); $("#btn-menu").innerHTML = Icons.svg("more");
$("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk")); $("#btn-kiosk").insertAdjacentHTML("afterbegin", Icons.svg("kiosk"));
$("#btn-app").innerHTML = Icons.svg("phone");
$("#phone-menu").innerHTML = Icons.svg("more");
document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon))); document.querySelectorAll("#menu [data-icon]").forEach((b) => b.insertAdjacentHTML("afterbegin", Icons.svg(b.dataset.icon)));
} }
@@ -3272,7 +3309,6 @@
const exists = (id) => id && store.dashboards.some((d) => d.id === id); const exists = (id) => id && store.dashboards.some((d) => d.id === id);
setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists)); setCurrent([hashId, dev?.dashboard, forUser, lsGet("ds.current")].find(exists));
if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false }); if (location.hash.includes("kiosk") || shared.get("ds_kiosk") === "1" || (dev?.kiosk && !hashId)) enterKiosk({ fullscreen: false });
setTimeout(phoneHint, 1500);
const action = shared.get("ds_action"); const action = shared.get("ds_action");
if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); } if (action) { history.replaceState(null, "", location.pathname + location.hash); runShortcut(action); }
// After an update, point at what changed (once per device). // After an update, point at what changed (once per device).
Binary file not shown.
+12 -1
View File
@@ -274,7 +274,6 @@
"Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek", "Resume": "Hervat", "Start": "Start", "Dock": "Dock", "Locate": "Zoek",
"Designed for phones": "Ontworpen voor telefoons", "Designed for phones": "Ontworpen voor telefoons",
"A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.": "Een indeling met 4 kolommen die je op telefoongrootte ontwerpt. Op een telefoon vult hij het scherm zonder de bovenbalk; houd een lege plek ingedrukt om de balk terug te halen.", "A 4-column layout you design at phone size. On a phone it fills the screen without the top bar; touch and hold an empty spot to bring the bar back.": "Een indeling met 4 kolommen die je op telefoongrootte ontwerpt. Op een telefoon vult hij het scherm zonder de bovenbalk; houd een lege plek ingedrukt om de balk terug te halen.",
"Touch and hold an empty spot to show the menu.": "Houd een lege plek ingedrukt om het menu te tonen.",
"Room": "Kamer", "Room name": "Naam van de kamer", "Temperature sensor": "Temperatuursensor", "Humidity sensor": "Vochtigheidssensor", "Room": "Kamer", "Room name": "Naam van de kamer", "Temperature sensor": "Temperatuursensor", "Humidity sensor": "Vochtigheidssensor",
"Extra entities (or all of them, without a room)": "Extra entiteiten (of allemaal, zonder kamer)", "Show devices": "Toon apparaten", "Extra entities (or all of them, without a room)": "Extra entiteiten (of allemaal, zonder kamer)", "Show devices": "Toon apparaten",
"Background image URL (optional)": "URL van achtergrondafbeelding (optioneel)", "Automatic": "Automatisch", "Background image URL (optional)": "URL van achtergrondafbeelding (optioneel)", "Automatic": "Automatisch",
@@ -614,6 +613,18 @@
"1 device isn’t on a dashboard": "1 apparaat staat niet op een dashboard", "1 device isn’t on a dashboard": "1 apparaat staat niet op een dashboard",
"{0} devices aren’t on a dashboard": "{0} apparaten staan niet op een dashboard", "{0} devices aren’t on a dashboard": "{0} apparaten staan niet op een dashboard",
"Devices not on a dashboard…": "Apparaten die niet op een dashboard staan…", "Devices not on a dashboard…": "Apparaten die niet op een dashboard staan…",
"Menu": "Menu",
"This is the Dashboard Studio app, connected to {0}.": "Dit is de Dashboard Studio-app, verbonden met {0}.",
"Change address": "Adres wijzigen",
"Get the Dashboard Studio app for Android. It opens full screen with its own icon and keeps you signed in.": "Gebruik de Dashboard Studio-app voor Android. Hij opent schermvullend met een eigen pictogram en houdt je ingelogd.",
"Download the app and open the file to install it. Android asks once to allow apps from this source.": "Download de app en open het bestand om hem te installeren. Android vraagt één keer of apps uit deze bron zijn toegestaan.",
"Come back here and tap Open in the app. Or open the app and enter the address of Home Assistant.": "Kom hier terug en tik op Openen in de app. Of open de app en vul het adres van Home Assistant in.",
"Or install it from the browser, without the app.": "Of installeer hem vanuit de browser, zonder de app.",
"Install from the browser": "Installeren vanuit de browser",
"Download the app": "App downloaden",
"Open in the app": "Openen in de app",
"On iPhone and iPad, apps added to the home screen can’t use Home Assistant’s sign-in, so Dashboard Studio can’t run on its own there. Open it from the sidebar in the Home Assistant app instead.": "Op iPhone en iPad kunnen apps op het beginscherm de inlog van Home Assistant niet gebruiken, dus daar kan Dashboard Studio niet los draaien. Open het in plaats daarvan via de zijbalk in de Home Assistant-app.",
"Browsers only install apps from a secure address (https), and Home Assistant is open over http here. Open Home Assistant through its https address, for example Home Assistant Cloud, and try again.": "Browsers installeren alleen apps vanaf een beveiligd adres (https), en Home Assistant is hier via http geopend. Open Home Assistant via het https-adres, bijvoorbeeld Home Assistant Cloud, en probeer het opnieuw.",
"Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet", "Today": "Vandaag", "Tomorrow": "Morgen", "All day": "Hele dag", "Busy": "Bezet",
}; };
+4 -1
View File
@@ -9,7 +9,8 @@
<title>Dashboard Studio</title> <title>Dashboard Studio</title>
<link rel="icon" type="image/png" href="static/favicon.png"> <link rel="icon" type="image/png" href="static/favicon.png">
<link rel="stylesheet" href="static/style.css"> <link rel="stylesheet" href="static/style.css">
<link rel="manifest" href="manifest.webmanifest"> <!-- Home Assistant only serves this app to a signed-in browser: the manifest has to be fetched with the cookie -->
<link rel="manifest" href="manifest.webmanifest" crossorigin="use-credentials">
<link rel="apple-touch-icon" href="static/apple-touch-icon.png"> <link rel="apple-touch-icon" href="static/apple-touch-icon.png">
<meta name="theme-color" content="#0f1526"> <meta name="theme-color" content="#0f1526">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
@@ -37,6 +38,7 @@
<button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button> <button id="btn-menu" class="btn icon" aria-label="More actions" aria-haspopup="menu" aria-expanded="false" title="More actions"></button>
</span> </span>
<button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button> <button id="btn-guest" class="btn view-only" hidden><span data-i18n>Guest mode</span></button>
<button id="btn-app" class="btn icon view-only" aria-label="Install as an app" title="Install as an app"></button>
<button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button> <button id="btn-kiosk" class="btn view-only label-wide" aria-label="Kiosk" title="Kiosk mode"><span data-i18n>Kiosk</span></button>
<button id="btn-edit" class="btn primary" data-i18n>Edit</button> <button id="btn-edit" class="btn primary" data-i18n>Edit</button>
</div> </div>
@@ -82,6 +84,7 @@
<div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div> <div id="saver" hidden aria-hidden="true"><div class="photos"><img class="ph-layer" alt=""><img class="ph-layer" alt=""><div class="ph-msg"></div></div><div class="saver-clock"><div class="time num"></div><div class="date"></div></div></div>
<div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div> <div id="doorbell" hidden role="alertdialog" aria-modal="true" aria-labelledby="db-title"><div class="db-cam"></div><div class="db-bar"><div class="db-title" id="db-title"></div><button class="btn db-close">Close</button></div></div>
<div id="night-dim" aria-hidden="true"></div> <div id="night-dim" aria-hidden="true"></div>
<button id="phone-menu" class="btn icon" aria-label="Menu" title="Menu"></button>
<div id="offline-note" hidden role="status"></div> <div id="offline-note" hidden role="status"></div>
<div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div> <div id="ptr" hidden aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7"/></svg></div>
<div id="announce" hidden role="alertdialog" aria-modal="true" aria-labelledby="an-title"><div class="an-card"><div class="an-ico"></div><div class="an-title" id="an-title"></div><div class="an-msg"></div><button class="btn primary an-ok">OK</button></div></div> <div id="announce" hidden role="alertdialog" aria-modal="true" aria-labelledby="an-title"><div class="an-card"><div class="an-ico"></div><div class="an-title" id="an-title"></div><div class="an-msg"></div><button class="btn primary an-ok">OK</button></div></div>
+9
View File
@@ -1327,6 +1327,15 @@ body:not(.editing) .w { -webkit-touch-callout: none; }
/* ============================================================ phone dashboards */ /* ============================================================ phone dashboards */
body.phone-ui:not(.peek) #topbar { display: none; } body.phone-ui:not(.peek) #topbar { display: none; }
#phone-menu { display: none; position: fixed; z-index: 62; top: calc(8px + env(safe-area-inset-top)); right: 10px; width: 36px; height: 36px; border-radius: 50%;
background: rgba(120, 120, 128, .28); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); color: var(--text); box-shadow: 0 2px 8px rgba(0, 0, 0, .15); }
#phone-menu svg { width: 20px; height: 20px; stroke-width: 2.6; }
body.phone-ui:not(.peek):not(.kiosk) #phone-menu { display: grid; }
/* the install button in the top bar is for phones and tablets; computers have it in the menu */
@media (pointer: fine) { #btn-app { display: none; } }
#btn-app[hidden] { display: none; }
a.btn { text-decoration: none; color: inherit; }
.modal-card ol.steps { margin: 0 0 12px; padding-left: 20px; display: grid; gap: 6px; }
body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); } body.phone-ui #stage { padding-top: calc(env(safe-area-inset-top) + 6px); }
body.phone-ui.peek #stage { padding-top: 72px; } body.phone-ui.peek #stage { padding-top: 72px; }
body.phone-ui.peek #topbar { animation: peek-in .2s ease-out; } body.phone-ui.peek #topbar { animation: peek-in .2s ease-out; }