diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md
new file mode 100644
index 0000000000..58d0284e59
--- /dev/null
+++ b/amethyst/plans/2026-09-26-browser-ui-review.md
@@ -0,0 +1,236 @@
+# Browser surfaces: UI review and redesign
+
+Status: **review + Compose prototypes**. The prototypes live in
+`commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered
+offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to
+`commonsUI/build/browser-pill/`. None of it is wired into the app yet (see §5).
+
+Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about
+how it looks and feels.
+
+## 1. What's wrong with what shipped
+
+Reviewed against the code as merged: `TopControlSheet`, `NappletControlSheet`, `BottomConsoleSheet`,
+`NappletConsolePanel`, `BrowserFindBar`, `EmbeddedFindBar`, `EmbeddedPageDialogs`, `BrowserJsDialogs`
+and the full-screen permission / page-info `AlertDialog`s.
+
+**Top pill**
+1. **It's a settings list, not a menu.** Up to 15 rows of equal weight in one scrolling column. Share,
+ copy and find (used daily) look exactly like desktop site and open-in-another-browser (used rarely).
+ Chrome solves this with an icon strip plus a short list; we copied the list and made it longer.
+2. **No visual hierarchy.** Section labels are 12sp caps you don't notice. Every row is the same 44dp
+ icon + label. Nothing groups.
+3. **Security state is a word, not a signal.** "Not secure" and "Onion-routed over Tor" are the same
+ grey 13sp text. Plain HTTP isn't warned about, and Tor, the thing Amethyst does that Chrome doesn't,
+ isn't celebrated.
+4. **Three rows are just switches** (Tor, desktop site, console). Each is a full-width row whose only
+ content is the switch. They say what they are, not what they mean ("Loads over Tor" vs "the site
+ can't see your IP").
+5. **The text-size stepper** is squeezed into a list row: two 24dp buttons and a percentage, with no
+ preview and no way back to 100%.
+6. **The collapsed grabber is mute.** It never shows loading, an insecure page, or console errors, so
+ there's nothing to invite the pull.
+
+**Inputs**
+
+7. **Edit address is a bare text field** dropped into the header. It has no clear button, no go button,
+ no suggestions (the launcher's omnibox has them), and no paste-and-go.
+8. **Find in page** is a full-width sheet with a default `TextField`. The count is loose text, and "no
+ matches" has no state of its own. The native version draws a different bar.
+9. **The JS prompt field** has no label. "Block dialogs from this page" is a third button squeezed in
+ beside Cancel/OK. Chrome uses a checkbox, because it's an option, not an answer.
+
+**Dialogs and sheets**
+
+10. **The permission prompt** is a stock `AlertDialog` with bullet text and only Allow / Block. There's
+ no "just this time", no icon for what's being asked, and nothing about the connection.
+11. **Page info** is a paragraph in an `AlertDialog` with three crowded buttons. The site's permissions
+ aren't there, and Clear site data runs immediately, with no confirmation.
+12. **The console** is a flat list with no filters (errors are buried in logs), no copy, and a count
+ that only shows inside the menu.
+
+**Parity**
+
+13. **Two renderers, two looks.** The full-screen pill is plain Views: framework `Switch`, framework
+ ripple, hand-rolled type sizes. Sharing the layout spec stopped the two drifting in content, but
+ they still don't look alike. `:nappletHost` already depends on `:commonsUI` (Compose), so the
+ full-screen window can host the same Compose pill in a `ComposeView`.
+
+## 2. Principles
+
+- **One tap for daily things, two for rare ones.** Navigation, star and share sit in a capsule.
+ Page actions are a grid of tiles. Privacy and developer settings are grouped cards below.
+- **State is visible, even collapsed.** The security colour (error for HTTP, Tor accent for onion),
+ loading progress and console errors show on the grabber itself.
+- **Inputs are first-class.** Every text input has:
+ - a real container and a leading icon that says what it is;
+ - a placeholder or label;
+ - clear and submit buttons;
+ - the right keyboard and IME action;
+ - an empty or error state.
+
+ Choices are segmented buttons or switches that state their consequence, never a bare "on".
+- **Explain consequences in plain words.** Say "The site can't see your IP address", not "Loads over
+ Tor". Say "Allow while visiting / Only this time / Don't allow", not "Allow / Block".
+- **One implementation.** Stateless composables in `commonsUI`, driven by `BrowserChrome` (already
+ shared). The embedded tab, the full-screen window (through `ComposeView`) and a future desktop browser
+ all draw the same pixels.
+
+## 3. The redesign
+
+### 3.1 Collapsed handle
+
+A 48×24dp capsule at the top centre holding a 32dp bar.
+- A 2dp progress line runs under the bar while the page loads.
+- The bar takes the error colour on HTTP and the Tor accent when onion-routed.
+- A 6dp error dot appears at the right edge when the console has errors.
+- It keeps its current gestures: pull or tap to open.
+
+### 3.2 Expanded pill
+
+```
+╭────────────────────────────────────────────╮
+│ [P] Primal ( ✕ ) │ monogram tile · title · close (full screen)
+│ ╭────────────────────────────────────────╮ │
+│ │ 🧅 primal.net ✎ │ │ origin field: tap edits the address
+│ ╰────────────────────────────────────────╯ │
+│ ⓘ You left primal.net [ Back to app ] │ only when out of scope
+│ ╭────────────────────────────────────────╮ │
+│ │ ← → ↻ ★ ⇪ │ │ navigation capsule
+│ ╰────────────────────────────────────────╯ │
+│ ╭────╮ ╭────╮ ╭────╮ ╭────╮ │
+│ │ ⧉ │ │ 🔍 │ │ Aa │ │ ⌂+ │ │ page actions (tiles, 4 per row)
+│ │Copy│ │Find│ │Text│ │Home│ │
+│ ╰────╯ ╰────╯ ╰────╯ ╰────╯ │
+│ ╭────╮ ╭────╮ ╭────╮ │ toggle tiles fill when on (Desktop)
+│ │ 🖥 │ │ ⬈ │ │ ⛶ │ │
+│ ╰────╯ ╰────╯ ╰────╯ │
+│ A ━━━━━━●━━━━━━━ A 115% Reset │ appears when "Text" is on
+│ ╭ Privacy ───────────────────────────────╮ │
+│ │ (🧅) Onion routing [ ● ] │ │
+│ │ The site can't see your IP │ │
+│ │ (⚙) Site settings › │ │
+│ │ Camera allowed · Location blocked │ │
+│ ╰────────────────────────────────────────╯ │
+│ >_ Console (3 errors) [ ] │
+╰────────────────────────────────────────────╯
+```
+
+- **Origin field.** It looks like an input on purpose (the address is one tap away without an address
+ bar taking space):
+ - a pill-shaped container on `surfaceContainerHighest`;
+ - the security icon in its colour;
+ - the host in `titleSmall`;
+ - a trailing pencil.
+
+ Tapping it opens the address editor (§3.3). Long-pressing copies the link.
+- **Navigation capsule.** Five 48dp icon buttons on `surfaceContainerHigh`, `CircleShape`. Back and
+ forward dim when they can't be used. The star fills in `primary` when the page is pinned. Reload
+ becomes Stop, with a progress ring, while loading.
+- **Tiles.** 72dp-tall rounded (16dp) cards on `surfaceContainer`, each with an icon and a two-line
+ `labelMedium` label. Toggle tiles (desktop site, text size open) switch to `secondaryContainer` with
+ a check badge.
+- **Privacy card.** Grouped rows on one rounded container:
+ - each row has a leading icon in a 36dp tinted circle, a title, and a supporting line that states the
+ consequence;
+ - a trailing switch, or a chevron for navigation;
+ - site settings summarise camera / mic / location decisions inline.
+- **Developer row.** The console with an error badge (`errorContainer` pill) and a switch. It's the
+ only developer item, so it gets no heading.
+- **Sandboxed apps** use the same frame:
+ - the origin field reads "Sandboxed app" with a shield and doesn't edit;
+ - the capsule is reload + star;
+ - the tiles are find and text size;
+ - "What it can access" joins the privacy card.
+
+### 3.3 Address editor (first-class input)
+
+Replaces the origin field inside the pill:
+- A 56dp pill field with the security/search icon leading, the URL selected, and trailing clear (✕)
+ and Go (a filled `primary` circle with an arrow). The keyboard is URI type with IME Go.
+- Below it:
+ - a **Paste and go** chip when the clipboard holds a URL;
+ - then up to five suggestions (favorites first, then history, from `OmniboxSuggestions`), each with a
+ monogram, the title and the URL;
+ - each suggestion has a trailing ↖ that fills its URL into the field without going.
+- Back or tapping outside collapses it to the origin field.
+
+### 3.4 Bottom: find in page and console
+
+- **Find pill.** A floating 56dp capsule, 12dp above the bottom edge, with shadow. It holds:
+ - a search icon;
+ - the field with placeholder "Find in page";
+ - a match chip ("3 / 12", tonal; "No matches" in `errorContainer`);
+ - up/down, a divider, then close.
+
+ The IME Search action jumps to the next match.
+- **Console sheet.** A drag handle, then a title with filter chips "All 42 · Errors 3 · Warnings 5",
+ then Copy and Clear. Log rows have:
+ - a 3dp level-coloured stripe;
+ - the message in monospace;
+ - `file:line` muted on the right;
+ - long-press to copy.
+
+ Errors show first when the Errors chip is on.
+
+### 3.5 Permission prompt
+
+A card sheet:
+- the origin field (read-only) on top;
+- 48dp tinted icon circles for each thing asked (camera / mic / location);
+- a title in plain words ("Use your camera and microphone?") and one body line;
+- when the site is on Tor and asks for camera or mic, a muted note: "Calls can reveal your IP address
+ even over Tor".
+
+Three stacked full-width buttons: **Allow while visiting** (filled, remembered), **Only this time**
+(tonal, not remembered), **Don't allow** (text, remembered).
+
+### 3.6 JS dialogs
+
+A card with:
+- the origin field as its header (so a page can't spoof Amethyst UI);
+- the message in `bodyLarge`, scrolling past 40% of the screen;
+- for `prompt`, an `OutlinedTextField` with a label, a clear button, autofocus, and IME Done that
+ submits;
+- a **"Don't let this page show more dialogs"** checkbox (second dialog onward);
+- right-aligned Cancel / OK. "Leave site?" uses a destructive-tinted Leave.
+
+### 3.7 Page info
+
+A sheet with:
+- **Header:** monogram, host, and the full origin.
+- **Connection:** rows with icons: encryption state (a warning tone for HTTP), then the route (Tor or
+ open web, with the same consequence line as the pill), then the certificate: issued to, issued by,
+ and expiry.
+- **Permissions:** one row per camera / mic / location with a segmented button **Ask · Allow ·
+ Block**, editable in place.
+- **Cookies and site data:** an outlined destructive button, "Clear site data". The first tap turns it
+ into an inline confirmation ("Sign out of this site and delete its data? · Cancel · Clear") instead
+ of acting at once.
+
+## 4. Tokens
+
+| Use | Token |
+|---|---|
+| Pill surface | `surface`, 0 tonal elevation, 6dp shadow, 24dp bottom corners |
+| Inputs, origin field | `surfaceContainerHighest`, `CircleShape` |
+| Navigation capsule | `surfaceContainerHigh`, `CircleShape` |
+| Tiles, grouped cards | `surfaceContainer`, 16dp |
+| Toggle "on" | `secondaryContainer` / `onSecondaryContainer` |
+| Insecure | `error` / `errorContainer` |
+| Tor accent | `tertiary` (no new colour, readable in both themes) |
+| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles |
+| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` |
+
+## 5. Next steps
+
+1. Wire the prototypes in: `TopControlSheet` → `BrowserPill`, `EmbeddedFindBar` → `FindInPagePill`,
+ `BottomConsoleSheet` → `ConsoleSheet`, and the embedded dialogs → `PermissionPromptCard` /
+ `PageDialogCard` / `PageInfoSheet`.
+2. Host the same composables in the full-screen window through `ComposeView`, and delete
+ `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel` and `BrowserJsDialogs`.
+3. Move the pill's labels from the `commons` Android resources to the Compose catalogue (the
+ prototypes already use `Res.string.browser_pill_*`).
+4. Add "Only this time" to the permission flow (the registry already treats "no answer" as
+ not-remembered).
+5. Confirm before clearing site data in both surfaces.
diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf
index 3af0f83bd1..05533f6459 100644
Binary files a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ
diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml
index b0fc2cb0e0..d1e8fac7c4 100644
--- a/commonsUI/src/commonMain/composeResources/values/strings.xml
+++ b/commonsUI/src/commonMain/composeResources/values/strings.xml
@@ -5351,4 +5351,104 @@
Unsigned, so this author is a claim
An item of kind %1$d, which this client does not draw
%1$d bytes of something this client does not read
+
+ Close
+ Copy link
+ Find
+ Text size
+ Desktop
+ Add to Home
+ Other browser
+ Full screen
+ Edit address
+ Back
+ Forward
+ Reload
+ Stop loading
+ Add to favorites
+ Remove from favorites
+ Share
+ Copy link
+ Find in page
+ Text size
+ Add to Home
+ Desktop site
+ Open in browser
+ Full screen
+ You left %1$s
+ Back to app
+ Smaller text
+ Larger text
+ Reset
+ %1$d%
+ Privacy
+ Onion routing
+ The site can't see your IP address
+ The site can see your IP address
+ Site settings
+ Nothing allowed yet
+ What it can access
+ Your keys never leave Amethyst
+ Console
+ Secure connection
+ Not secure
+ Onion-routed
+ Sandboxed app
+ Camera
+ Microphone
+ Location
+ Ask
+ Allow
+ Block
+ %1$s %2$s
+ allowed
+ blocked
+ Search or enter address
+ Go
+ Clear
+ Paste and go
+ Use this address
+ Find in page
+ %1$d / %2$d
+ No matches
+ Previous match
+ Next match
+ Close find in page
+ All
+ Errors
+ Warnings
+ Copy
+ Clear
+ Nothing logged yet
+ This site wants to use
+ See what your camera sees
+ Hear what your microphone hears
+ Know roughly where you are
+ Calls can reveal your IP address even over Tor.
+ Allow while visiting
+ Only this time
+ Don't allow
+ %1$s says
+ This page says
+ Don't let this page show more dialogs
+ Your answer
+ Leave site?
+ Changes you made may not be saved.
+ Leave
+ OK
+ Cancel
+ Connection
+ Connection is encrypted
+ Information you send can't be read on the way.
+ Connection is not encrypted
+ Don't enter passwords or payment details.
+ Onion-routed over Tor
+ Open web
+ Certificate
+ Issued to %1$s by %2$s · valid until %3$s
+ Permissions
+ Cookies and site data
+ Logins and preferences this site saved for this account
+ Clear site data
+ Sign out of this site and delete its data?
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt
new file mode 100644
index 0000000000..f46a97d35e
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt
@@ -0,0 +1,219 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.AssistChip
+import androidx.compose.material3.AssistChipDefaults
+import androidx.compose.material3.FilledIconButton
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_address_hint
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_fill_in
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_go
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_paste_go
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/**
+ * The address as a first-class input, opened from the origin field. A 56dp pill field with the URL
+ * pre-selected, a leading search/security icon, and trailing clear + a filled Go; below it Paste and go
+ * (when the clipboard holds a URL) and the omnibox suggestions, each with a ↖ that fills its address in
+ * without leaving. [onCancel] folds it back into the origin field.
+ */
+@Composable
+fun AddressEditor(
+ initialUrl: String,
+ security: BrowserChrome.Security,
+ suggestions: List,
+ clipboardUrl: String?,
+ onGo: (String) -> Unit,
+ onCancel: () -> Unit,
+ modifier: Modifier = Modifier,
+ autoFocus: Boolean = true,
+) {
+ var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) }
+ val focus = remember { FocusRequester() }
+ if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
+
+ fun go(text: String = field.text) {
+ text.trim().takeIf { it.isNotEmpty() }?.let(onGo)
+ }
+
+ Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ IconButton(onClick = onCancel) {
+ Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null)
+ }
+ Row(
+ Modifier
+ .weight(1f)
+ .height(56.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape)
+ .padding(start = 16.dp, end = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // The field shows what it will do: the page's badge while it still holds the page's URL,
+ // a search glyph once the user types something else.
+ if (field.text == initialUrl) {
+ SecurityIcon(security, size = 20.dp)
+ } else {
+ Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.width(10.dp))
+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
+ if (field.text.isEmpty()) {
+ Text(stringRes(Res.string.browser_pill_address_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ BasicTextField(
+ value = field,
+ onValueChange = { field = it },
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ keyboardOptions =
+ KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ autoCorrectEnabled = false,
+ keyboardType = KeyboardType.Uri,
+ imeAction = ImeAction.Go,
+ ),
+ keyboardActions = KeyboardActions(onGo = { go() }),
+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
+ )
+ }
+ if (field.text.isNotEmpty()) {
+ IconButton(onClick = { field = TextFieldValue("") }) {
+ Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ FilledIconButton(onClick = { go() }, enabled = field.text.isNotBlank(), modifier = Modifier.size(44.dp)) {
+ Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_pill_go), modifier = Modifier.size(22.dp))
+ }
+ }
+ }
+
+ if (clipboardUrl != null && clipboardUrl != field.text) {
+ AssistChip(
+ onClick = { go(clipboardUrl) },
+ label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) },
+ modifier = Modifier.padding(start = 48.dp),
+ )
+ }
+
+ if (suggestions.isNotEmpty()) {
+ Column(Modifier.padding(start = 4.dp)) {
+ suggestions.take(MAX_SUGGESTIONS).forEach { suggestion ->
+ SuggestionRow(
+ suggestion = suggestion,
+ onOpen = { go(suggestion.url) },
+ onFill = { field = TextFieldValue(suggestion.url, TextRange(suggestion.url.length)) },
+ )
+ }
+ }
+ }
+ }
+}
+
+private const val MAX_SUGGESTIONS = 5
+
+@Composable
+private fun SuggestionRow(
+ suggestion: AddressSuggestion,
+ onOpen: () -> Unit,
+ onFill: () -> Unit,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(MaterialTheme.shapes.medium)
+ .clickable(onClick = onOpen)
+ .padding(start = 8.dp, top = 6.dp, bottom = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box {
+ SiteMonogram(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, size = 32.dp)
+ if (suggestion.isFavorite) {
+ Box(
+ Modifier
+ .align(Alignment.BottomEnd)
+ .size(14.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(MaterialSymbols.Star, contentDescription = null, modifier = Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary, filled = true)
+ }
+ }
+ }
+ Spacer(Modifier.width(14.dp))
+ Column(Modifier.weight(1f)) {
+ Text(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(suggestion.url.removePrefix("https://"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ IconButton(onClick = onFill) {
+ Icon(MaterialSymbols.NorthWest, contentDescription = stringRes(Res.string.browser_pill_fill_in), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt
new file mode 100644
index 0000000000..e7bd6ca2a3
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt
@@ -0,0 +1,503 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.Orientation
+import androidx.compose.foundation.gestures.draggable
+import androidx.compose.foundation.gestures.rememberDraggableState
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_close
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allowed
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_blocked
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_left_site
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_state
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_privacy
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings_none
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/**
+ * The redesigned browser pill (see `amethyst/plans/2026-09-26-browser-ui-review.md`): a grabber at the
+ * top centre that shows the page's state even collapsed, and, pulled down, a Chrome-PWA-style menu —
+ * header, navigation capsule, page-action tiles, a privacy card and the console row.
+ *
+ * Stateless apart from which inline panel (address editor, text size) is open. Which controls appear is
+ * [BrowserChrome]'s decision, so this renders web, nsite and napplet surfaces alike.
+ */
+@Composable
+fun BrowserPill(
+ ui: BrowserPillUi,
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ onEvent: (BrowserPillEvent) -> Unit,
+ modifier: Modifier = Modifier,
+ showClose: Boolean = false,
+ suggestions: List = emptyList(),
+ clipboardUrl: String? = null,
+ initiallyEditing: Boolean = false,
+ initiallyTextSizeOpen: Boolean = false,
+) {
+ Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
+ AnimatedVisibility(
+ visible = expanded,
+ enter = expandVertically() + fadeIn(),
+ exit = shrinkVertically() + fadeOut(),
+ ) {
+ BrowserPillSheet(
+ ui = ui,
+ onEvent = { event ->
+ // Everything but in-sheet adjustments (text size) finishes the interaction.
+ if (event !is BrowserPillEvent.TextZoom) onExpandedChange(false)
+ onEvent(event)
+ },
+ showClose = showClose,
+ suggestions = suggestions,
+ clipboardUrl = clipboardUrl,
+ initiallyEditing = initiallyEditing,
+ initiallyTextSizeOpen = initiallyTextSizeOpen,
+ )
+ }
+ PillHandle(ui, expanded, onExpandedChange)
+ }
+}
+
+/**
+ * The collapsed grabber. It tells the page's story before it's opened: the bar takes the error colour on
+ * plain HTTP and the Tor accent when onion-routed, a hairline fills while the page loads, and a dot
+ * appears when the console has errors.
+ */
+@Composable
+fun PillHandle(
+ ui: BrowserPillUi,
+ expanded: Boolean,
+ onExpandedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val barColor =
+ when (ui.security) {
+ BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error
+ BrowserChrome.Security.TOR -> MaterialTheme.colorScheme.tertiary
+ else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
+ }
+ Box(
+ modifier
+ .clip(RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp))
+ .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f))
+ .clickable { onExpandedChange(!expanded) }
+ .draggable(
+ orientation = Orientation.Vertical,
+ state =
+ rememberDraggableState { delta ->
+ if (delta > 1f) {
+ onExpandedChange(true)
+ } else if (delta < -1f) {
+ onExpandedChange(false)
+ }
+ },
+ ).padding(horizontal = 18.dp, vertical = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ Box(
+ Modifier
+ .width(36.dp)
+ .height(5.dp)
+ .clip(CircleShape)
+ .background(barColor),
+ )
+ val progress = ui.loadProgress
+ if (progress != null) {
+ Spacer(Modifier.height(3.dp))
+ LinearProgressIndicator(
+ progress = { progress },
+ modifier = Modifier.width(36.dp).height(2.dp).clip(CircleShape),
+ trackColor = Color.Transparent,
+ drawStopIndicator = {},
+ )
+ }
+ }
+ if (ui.consoleErrors > 0) {
+ Box(
+ Modifier
+ .align(Alignment.CenterEnd)
+ .padding(start = 44.dp)
+ .size(7.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.error),
+ )
+ }
+ }
+}
+
+/** The expanded menu (without the grabber), for hosts that place it themselves. */
+@Composable
+fun BrowserPillSheet(
+ ui: BrowserPillUi,
+ onEvent: (BrowserPillEvent) -> Unit,
+ modifier: Modifier = Modifier,
+ showClose: Boolean = false,
+ suggestions: List = emptyList(),
+ clipboardUrl: String? = null,
+ initiallyEditing: Boolean = false,
+ initiallyTextSizeOpen: Boolean = false,
+) {
+ var editing by rememberSaveable { mutableStateOf(initiallyEditing) }
+ var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) }
+ val sections = remember(ui.chrome) { BrowserChrome.sections(ui.chrome) }
+ val page = sections.firstOrNull { it.kind == SectionKind.PAGE }?.actions.orEmpty()
+ val privacy = sections.firstOrNull { it.kind == SectionKind.PRIVACY }?.actions.orEmpty()
+ val developer = sections.firstOrNull { it.kind == SectionKind.DEVELOPER }?.actions.orEmpty()
+
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = PillDefaults.SheetShape,
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 0.dp,
+ shadowElevation = 8.dp,
+ // On the black dark theme a shadow doesn't show; a hairline keeps the sheet's edge off the page.
+ border = PillDefaults.hairline(),
+ ) {
+ Column(
+ Modifier
+ .verticalScroll(rememberScrollState())
+ .padding(start = PillDefaults.SheetPadding, end = PillDefaults.SheetPadding, top = 12.dp, bottom = 12.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ if (editing) {
+ AddressEditor(
+ initialUrl = ui.chrome.url,
+ security = ui.security,
+ suggestions = suggestions,
+ clipboardUrl = clipboardUrl,
+ onGo = { onEvent(BrowserPillEvent.Navigate(it)) },
+ onCancel = { editing = false },
+ )
+ } else {
+ PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) })
+ OriginField(
+ ui = ui,
+ onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null,
+ onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) else null,
+ )
+ if (Action.BACK_TO_APP in page) {
+ OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) {
+ onEvent(BrowserPillEvent.Action(Action.BACK_TO_APP))
+ }
+ }
+ NavigationCapsule(ui, onAction = { onEvent(BrowserPillEvent.Action(it)) })
+
+ val tiles = page.filter { it != Action.BACK_TO_APP && it != Action.EDIT_ADDRESS }
+ if (tiles.isNotEmpty()) {
+ TileGrid(
+ actions = tiles,
+ ui = ui,
+ textSizeOpen = textSizeOpen,
+ onAction = { action ->
+ if (action == Action.TEXT_SIZE) textSizeOpen = !textSizeOpen else onEvent(BrowserPillEvent.Action(action))
+ },
+ )
+ }
+ AnimatedVisibility(visible = textSizeOpen) {
+ TextSizeControl(ui.textZoom) { onEvent(BrowserPillEvent.TextZoom(it)) }
+ }
+ if (privacy.isNotEmpty()) {
+ PrivacyCard(ui, privacy) { onEvent(BrowserPillEvent.Action(it)) }
+ }
+ if (Action.CONSOLE in developer) {
+ ConsoleRow(ui) { onEvent(BrowserPillEvent.Action(Action.CONSOLE)) }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PillHeader(
+ ui: BrowserPillUi,
+ showClose: Boolean,
+ onClose: () -> Unit,
+) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ SiteMonogram(ui.title.ifBlank { ui.host })
+ Spacer(Modifier.width(12.dp))
+ Text(
+ ui.title.ifBlank { ui.host },
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f),
+ )
+ if (showClose) {
+ IconButton(onClick = onClose) {
+ Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_close), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ }
+ }
+}
+
+/** Chrome's out-of-scope bar, inside the menu: you're on another site now; one tap goes home. */
+@Composable
+private fun OutOfScopeBanner(
+ homeHost: String,
+ onBack: () -> Unit,
+) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.secondaryContainer)
+ .padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(MaterialSymbols.Info, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer)
+ Spacer(Modifier.width(10.dp))
+ Text(
+ stringRes(Res.string.browser_pill_left_site, homeHost),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ TextButton(onClick = onBack) { Text(stringRes(Res.string.browser_pill_back_to_app)) }
+ }
+}
+
+/** Page actions as tiles, four per row; toggles (desktop site, text size) fill while on. */
+@Composable
+private fun TileGrid(
+ actions: List,
+ ui: BrowserPillUi,
+ textSizeOpen: Boolean,
+ onAction: (Action) -> Unit,
+) {
+ val columns = 4
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ actions.chunked(columns).forEach { rowActions ->
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ rowActions.forEach { action ->
+ ActionTile(
+ symbol = pillSymbolFor(action) ?: MaterialSymbols.Info,
+ label = stringRes(pillTileLabelFor(action)),
+ description = stringRes(pillLabelFor(action)),
+ onClick = { onAction(action) },
+ selected =
+ when (action) {
+ Action.DESKTOP_SITE -> ui.desktopSite
+ Action.TEXT_SIZE -> textSizeOpen || ui.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM
+ else -> null
+ },
+ modifier = Modifier.weight(1f),
+ )
+ }
+ // Keep the last row's tiles the same width as the others.
+ repeat(columns - rowActions.size) { Spacer(Modifier.weight(1f)) }
+ }
+ }
+ }
+}
+
+/** Text size: a stepped slider between a small and a large "A", the value, and a way back to 100%. */
+@Composable
+fun TextSizeControl(
+ percent: Int,
+ onChange: (Int) -> Unit,
+) {
+ val steps = BrowserChrome.TEXT_ZOOM_STEPS
+ val index = steps.indexOfFirst { it >= percent }.let { if (it < 0) steps.lastIndex else it }
+ Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) {
+ Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ val smaller = stringRes(Res.string.browser_pill_text_smaller)
+ IconButton(
+ onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = false)) },
+ modifier = Modifier.semantics { contentDescription = smaller },
+ ) {
+ Text("A", fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 4.dp))
+ }
+ Slider(
+ value = index.toFloat(),
+ onValueChange = { onChange(steps[it.toInt().coerceIn(0, steps.lastIndex)]) },
+ valueRange = 0f..steps.lastIndex.toFloat(),
+ steps = steps.size - 2,
+ modifier = Modifier.weight(1f),
+ )
+ val larger = stringRes(Res.string.browser_pill_text_larger)
+ IconButton(
+ onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = true)) },
+ modifier = Modifier.semantics { contentDescription = larger },
+ ) {
+ Text("A", fontSize = 22.sp, fontWeight = FontWeight.Medium)
+ }
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ stringRes(Res.string.browser_pill_text_value, percent),
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.weight(1f).padding(start = 12.dp),
+ )
+ TextButton(onClick = { onChange(BrowserChrome.DEFAULT_TEXT_ZOOM) }, enabled = percent != BrowserChrome.DEFAULT_TEXT_ZOOM) {
+ Text(stringRes(Res.string.browser_pill_text_reset))
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Privacy: Tor with what it means for the site, site settings with a summary of what the site may use,
+ * and, for sandboxed apps, what they can access.
+ */
+@Composable
+private fun PrivacyCard(
+ ui: BrowserPillUi,
+ actions: List,
+ onAction: (Action) -> Unit,
+) {
+ GroupCard(heading = stringRes(Res.string.browser_pill_privacy)) {
+ actions.forEachIndexed { index, action ->
+ if (index > 0) GroupDivider()
+ when (action) {
+ Action.TOR -> {
+ val on = ui.chrome.torOn == true
+ GroupRow(
+ icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
+ iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
+ title = stringRes(pillLabelFor(Action.TOR)),
+ supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
+ onClick = { onAction(Action.TOR) },
+ ) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) }
+ }
+ Action.SITE_SETTINGS ->
+ GroupRow(
+ icon = { PillActionIcon(Action.SITE_SETTINGS, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
+ iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest,
+ title = stringRes(pillLabelFor(Action.SITE_SETTINGS)),
+ supporting = permissionSummary(ui.sitePermissions),
+ onClick = { onAction(Action.SITE_SETTINGS) },
+ ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }
+ Action.ACCESS_INFO ->
+ GroupRow(
+ icon = { PillActionIcon(Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onPrimaryContainer, size = 22.dp, filled = true) },
+ iconContainer = MaterialTheme.colorScheme.primaryContainer,
+ title = stringRes(pillLabelFor(Action.ACCESS_INFO)),
+ supporting = stringRes(Res.string.browser_pill_access_desc),
+ onClick = { onAction(Action.ACCESS_INFO) },
+ ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }
+ else -> Unit
+ }
+ }
+ }
+}
+
+@Composable
+private fun permissionSummary(decisions: Map): String {
+ val answered = BrowserSitePermission.entries.mapNotNull { permission -> decisions[permission]?.takeIf { it != BrowserSitePermission.Decision.ASK }?.let { permission to it } }
+ if (answered.isEmpty()) return stringRes(Res.string.browser_pill_site_settings_none)
+ return answered
+ .map { (permission, decision) ->
+ stringRes(
+ Res.string.browser_pill_permission_state,
+ stringRes(permissionLabel(permission)),
+ stringRes(if (decision == BrowserSitePermission.Decision.ALLOW) Res.string.browser_pill_decision_allowed else Res.string.browser_pill_decision_blocked),
+ )
+ }.joinToString(" · ")
+}
+
+/** The developer console: one row, with an error count badge and its on/off switch. */
+@Composable
+private fun ConsoleRow(
+ ui: BrowserPillUi,
+ onToggle: () -> Unit,
+) {
+ Surface(onClick = onToggle, shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) {
+ Row(Modifier.padding(start = 16.dp, end = 12.dp).heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) {
+ PillActionIcon(Action.CONSOLE, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp)
+ Spacer(Modifier.width(16.dp))
+ Text(stringRes(pillLabelFor(Action.CONSOLE)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
+ if (ui.consoleErrors > 0) {
+ CountBadge(ui.consoleErrors)
+ Spacer(Modifier.width(12.dp))
+ }
+ Switch(checked = ui.consoleShowing, onCheckedChange = { onToggle() })
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt
new file mode 100644
index 0000000000..b9c78e7e1f
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.runtime.Immutable
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+
+/**
+ * Everything the redesigned browser pill draws, as plain immutable data. [chrome] decides *which* controls
+ * exist (the same [BrowserChrome] layout both browsers already share); the rest is what they show.
+ *
+ * The pill composables are stateless: a host (embedded tab, full-screen window, desktop) builds this from
+ * its live page and handles [BrowserPillEvent]s.
+ */
+@Immutable
+data class BrowserPillUi(
+ val title: String,
+ val chrome: BrowserChrome.State,
+ val isFavorite: Boolean = false,
+ val desktopSite: Boolean = false,
+ val textZoom: Int = BrowserChrome.DEFAULT_TEXT_ZOOM,
+ /** 0..1 while the main frame loads; null when idle. */
+ val loadProgress: Float? = null,
+ val consoleShowing: Boolean = false,
+ val consoleErrors: Int = 0,
+ /** Answers this site already has (camera / mic / location), for the site-settings summary. */
+ val sitePermissions: Map = emptyMap(),
+) {
+ val security: BrowserChrome.Security get() = BrowserChrome.security(chrome)
+ val host: String get() = BrowserChrome.displayHost(chrome.url)
+}
+
+/** What the user did in the pill. Hosts map these onto the WebView / broker. */
+sealed interface BrowserPillEvent {
+ /** A navigation-capsule button, a tile, or a privacy/developer row. */
+ data class Action(
+ val action: BrowserChrome.Action,
+ ) : BrowserPillEvent
+
+ data class TextZoom(
+ val percent: Int,
+ ) : BrowserPillEvent
+
+ data class Navigate(
+ val input: String,
+ ) : BrowserPillEvent
+
+ /** The origin field was long-pressed (copy link). */
+ data object CopyOrigin : BrowserPillEvent
+
+ data object Close : BrowserPillEvent
+}
+
+/** One omnibox suggestion for the address editor. */
+@Immutable
+data class AddressSuggestion(
+ val title: String,
+ val url: String,
+ val isFavorite: Boolean = false,
+)
+
+/** One console line. */
+@Immutable
+data class ConsoleLine(
+ val level: Level,
+ val message: String,
+ val source: String = "",
+ val line: Int = 0,
+) {
+ enum class Level { LOG, INFO, WARNING, ERROR, DEBUG }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt
new file mode 100644
index 0000000000..fce006a7e9
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt
@@ -0,0 +1,240 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
+import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
+
+/** Sample states for the pill prototypes, shared by the previews and the offscreen render test. */
+object BrowserPillSamples {
+ val primal =
+ BrowserPillUi(
+ title = "Primal",
+ chrome =
+ BrowserChrome.State(
+ surface = BrowserChrome.Surface.WEB,
+ presentation = BrowserChrome.Presentation.FULL_SCREEN,
+ url = "https://primal.net/home",
+ startUrl = "https://primal.net/",
+ canGoBack = true,
+ torOn = false,
+ ),
+ isFavorite = true,
+ consoleErrors = 3,
+ sitePermissions = mapOf(BrowserSitePermission.CAMERA to Decision.ALLOW, BrowserSitePermission.LOCATION to Decision.BLOCK),
+ )
+
+ val overTorOutOfScope =
+ BrowserPillUi(
+ title = "Sign in – Accounts",
+ chrome =
+ BrowserChrome.State(
+ surface = BrowserChrome.Surface.WEB,
+ presentation = BrowserChrome.Presentation.EMBEDDED,
+ url = "https://accounts.example.com/login",
+ startUrl = "https://snort.social/",
+ canGoBack = true,
+ canGoForward = true,
+ isLoading = true,
+ torOn = true,
+ ),
+ loadProgress = 0.62f,
+ textZoom = 115,
+ desktopSite = true,
+ )
+
+ val insecure =
+ BrowserPillUi(
+ title = "Old Forum",
+ chrome = BrowserChrome.State(BrowserChrome.Surface.WEB, BrowserChrome.Presentation.EMBEDDED, "http://oldforum.example.org/", "http://oldforum.example.org/"),
+ loadProgress = 0.3f,
+ )
+
+ val napplet =
+ BrowserPillUi(
+ title = "Zap Poll",
+ chrome =
+ BrowserChrome.State(
+ surface = BrowserChrome.Surface.NAPPLET,
+ presentation = BrowserChrome.Presentation.FULL_SCREEN,
+ url = "",
+ startUrl = "",
+ canFavorite = true,
+ hasAccessInfo = true,
+ torOn = true,
+ ),
+ )
+
+ val suggestions =
+ listOf(
+ AddressSuggestion("Primal", "https://primal.net/", isFavorite = true),
+ AddressSuggestion("Snort", "https://snort.social/"),
+ AddressSuggestion("Habla — long-form Nostr", "https://habla.news/"),
+ AddressSuggestion("", "https://nostr.band/search?q=amethyst"),
+ )
+
+ val console =
+ listOf(
+ ConsoleLine(ConsoleLine.Level.INFO, "Connected to wss://relay.damus.io", "app.js", 112),
+ ConsoleLine(ConsoleLine.Level.WARNING, "window.nostr.getRelays is deprecated", "nostr.js", 40),
+ ConsoleLine(ConsoleLine.Level.LOG, "feed: 42 events in 180ms", "feed.js", 9),
+ ConsoleLine(ConsoleLine.Level.ERROR, "Uncaught TypeError: Cannot read properties of undefined (reading 'pubkey')", "https://primal.net/assets/index-4f2a.js", 2211),
+ ConsoleLine(ConsoleLine.Level.ERROR, "Failed to load (-2): net::ERR_NAME_NOT_RESOLVED", "https://cdn.example.com/x.png", 0),
+ ConsoleLine(ConsoleLine.Level.DEBUG, "cache hit: profile 7a1c…", "cache.js", 77),
+ )
+
+ val certificate = CertificateInfo(issuedTo = "primal.net", issuedBy = "Let's Encrypt", validUntil = "Dec 14, 2026")
+}
+
+@Composable
+private fun PreviewFrame(content: @Composable () -> Unit) {
+ ThemeComparisonRow {
+ Box(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(bottom = 12.dp)) { content() }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 1000)
+@Composable
+fun BrowserPillExpandedPreview() {
+ PreviewFrame { BrowserPill(BrowserPillSamples.primal, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) }
+}
+
+@Preview(widthDp = 820, heightDp = 1100)
+@Composable
+fun BrowserPillTorOutOfScopePreview() {
+ PreviewFrame { BrowserPill(BrowserPillSamples.overTorOutOfScope, expanded = true, onExpandedChange = {}, onEvent = {}, initiallyTextSizeOpen = true) }
+}
+
+@Preview(widthDp = 820, heightDp = 700)
+@Composable
+fun BrowserPillAddressEditorPreview() {
+ PreviewFrame {
+ BrowserPill(
+ BrowserPillSamples.primal,
+ expanded = true,
+ onExpandedChange = {},
+ onEvent = {},
+ suggestions = BrowserPillSamples.suggestions,
+ clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z",
+ initiallyEditing = true,
+ )
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 800)
+@Composable
+fun BrowserPillNappletPreview() {
+ PreviewFrame { BrowserPill(BrowserPillSamples.napplet, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) }
+}
+
+@Preview(widthDp = 820, heightDp = 160)
+@Composable
+fun PillHandlesPreview() {
+ ThemeComparisonRow {
+ Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(vertical = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top) {
+ PillHandle(BrowserPillSamples.primal.copy(consoleErrors = 0), expanded = false, onExpandedChange = {})
+ PillHandle(BrowserPillSamples.insecure, expanded = false, onExpandedChange = {})
+ PillHandle(BrowserPillSamples.overTorOutOfScope.copy(consoleErrors = 2), expanded = false, onExpandedChange = {})
+ }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 220)
+@Composable
+fun FindInPagePreview() {
+ ThemeComparisonRow {
+ Column(Modifier.background(MaterialTheme.colorScheme.surfaceDim)) {
+ FindInPagePill(query = "zap", onQueryChange = {}, active = 2, total = 12, onNext = {}, onClose = {}, autoFocus = false)
+ FindInPagePill(query = "lightning address", onQueryChange = {}, active = 0, total = 0, onNext = {}, onClose = {}, autoFocus = false)
+ }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 380)
+@Composable
+fun ConsoleSheetPreview() {
+ ThemeComparisonRow { ConsoleSheet(BrowserPillSamples.console, onCopy = {}, onClear = {}, onCopyLine = {}) }
+}
+
+@Preview(widthDp = 820, heightDp = 700)
+@Composable
+fun PermissionPromptPreview() {
+ PreviewFrame {
+ Box(Modifier.padding(16.dp)) {
+ PermissionPromptCard("meet.example.com", BrowserChrome.Security.TOR, setOf(BrowserSitePermission.CAMERA, BrowserSitePermission.MICROPHONE), onAllow = {}, onAllowOnce = {}, onDeny = {})
+ }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 560)
+@Composable
+fun PageDialogPreview() {
+ PreviewFrame {
+ Box(Modifier.padding(16.dp)) {
+ PageDialogCard(
+ type = PageDialogType.PROMPT,
+ host = "snort.social",
+ security = BrowserChrome.Security.HTTPS,
+ message = "Name this relay set",
+ defaultValue = "Friends",
+ offerBlock = true,
+ onResult = { _, _, _ -> },
+ autoFocus = false,
+ )
+ }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 420)
+@Composable
+fun LeaveSiteDialogPreview() {
+ PreviewFrame {
+ Box(Modifier.padding(16.dp)) {
+ PageDialogCard(PageDialogType.BEFORE_UNLOAD, "habla.news", BrowserChrome.Security.HTTPS, message = "", onResult = { _, _, _ -> })
+ }
+ }
+}
+
+@Preview(widthDp = 820, heightDp = 1250)
+@Composable
+fun PageInfoPreview() {
+ PreviewFrame {
+ Box(Modifier.padding(16.dp).width(380.dp)) {
+ PageInfoSheet(BrowserPillSamples.primal, BrowserPillSamples.certificate, onPermissionChange = { _, _ -> }, onClearSiteData = {}, initiallyConfirmingClear = true)
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt
new file mode 100644
index 0000000000..28de782946
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt
@@ -0,0 +1,231 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_copy
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_empty
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_errors
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_warnings
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/** Which console lines are shown. */
+enum class ConsoleFilter { ALL, ERRORS, WARNINGS }
+
+/**
+ * The developer console as a bottom sheet: a grab handle, the title with All / Errors / Warnings filter
+ * chips (with counts), Copy and Clear, then the log — one row per line with a level-coloured stripe, the
+ * message in monospace and `file:line` muted. Long-press a row to copy it.
+ */
+@Composable
+fun ConsoleSheet(
+ lines: List,
+ onCopy: (List) -> Unit,
+ onClear: () -> Unit,
+ onCopyLine: (ConsoleLine) -> Unit,
+ modifier: Modifier = Modifier,
+ maxHeight: Dp = 320.dp,
+ initialFilter: ConsoleFilter = ConsoleFilter.ALL,
+) {
+ var filter by remember { mutableStateOf(initialFilter) }
+ val errors = lines.count { it.level == ConsoleLine.Level.ERROR }
+ val warnings = lines.count { it.level == ConsoleLine.Level.WARNING }
+ val shown =
+ when (filter) {
+ ConsoleFilter.ALL -> lines
+ ConsoleFilter.ERRORS -> lines.filter { it.level == ConsoleLine.Level.ERROR }
+ ConsoleFilter.WARNINGS -> lines.filter { it.level == ConsoleLine.Level.WARNING }
+ }
+
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ shadowElevation = 8.dp,
+ border = PillDefaults.hairline(),
+ ) {
+ Column(Modifier.heightIn(max = maxHeight)) {
+ Box(Modifier.fillMaxWidth().padding(top = 10.dp), contentAlignment = Alignment.Center) {
+ Box(
+ Modifier
+ .width(32.dp)
+ .height(4.dp)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)),
+ )
+ }
+ Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) {
+ Text(stringRes(Res.string.browser_pill_console), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
+ IconButton(onClick = { onCopy(shown) }, enabled = shown.isNotEmpty()) {
+ Icon(MaterialSymbols.ContentCopy, contentDescription = stringRes(Res.string.browser_pill_console_copy), modifier = Modifier.size(20.dp))
+ }
+ IconButton(onClick = onClear, enabled = lines.isNotEmpty()) {
+ Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp))
+ }
+ }
+ Row(
+ Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ ConsoleChip(stringRes(Res.string.browser_pill_console_all), lines.size, filter == ConsoleFilter.ALL) { filter = ConsoleFilter.ALL }
+ ConsoleChip(stringRes(Res.string.browser_pill_console_errors), errors, filter == ConsoleFilter.ERRORS, MaterialTheme.colorScheme.error) { filter = ConsoleFilter.ERRORS }
+ ConsoleChip(stringRes(Res.string.browser_pill_console_warnings), warnings, filter == ConsoleFilter.WARNINGS, warningColor()) { filter = ConsoleFilter.WARNINGS }
+ }
+ if (shown.isEmpty()) {
+ Text(
+ stringRes(Res.string.browser_pill_console_empty),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp),
+ )
+ } else {
+ LazyColumn(Modifier.padding(top = 6.dp, bottom = 8.dp)) {
+ items(shown) { line -> ConsoleRowItem(line) { onCopyLine(line) } }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun ConsoleChip(
+ label: String,
+ count: Int,
+ selected: Boolean,
+ dot: Color? = null,
+ onClick: () -> Unit,
+) {
+ FilterChip(
+ selected = selected,
+ onClick = onClick,
+ label = { Text("$label $count") },
+ leadingIcon =
+ dot?.let { color ->
+ { Box(Modifier.size(8.dp).clip(CircleShape).background(color)) }
+ },
+ colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer),
+ )
+}
+
+@Composable
+private fun warningColor(): Color = if (MaterialTheme.colorScheme.surface.luminanceBelowHalf()) Color(0xFFFFB74D) else Color(0xFFB45309)
+
+private fun Color.luminanceBelowHalf(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ConsoleRowItem(
+ line: ConsoleLine,
+ onLongPress: () -> Unit,
+) {
+ val color =
+ when (line.level) {
+ ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.error
+ ConsoleLine.Level.WARNING -> warningColor()
+ ConsoleLine.Level.DEBUG -> MaterialTheme.colorScheme.onSurfaceVariant
+ else -> MaterialTheme.colorScheme.onSurface
+ }
+ val background =
+ when (line.level) {
+ ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f)
+ ConsoleLine.Level.WARNING -> warningColor().copy(alpha = 0.10f)
+ else -> Color.Transparent
+ }
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .background(background)
+ .combinedClickable(onClick = {}, onLongClick = onLongPress)
+ .padding(end = 16.dp),
+ verticalAlignment = Alignment.Top,
+ ) {
+ Box(Modifier.width(3.dp).fillMaxHeight().background(if (line.level == ConsoleLine.Level.LOG) Color.Transparent else color))
+ Spacer(Modifier.width(13.dp))
+ Text(
+ line.message,
+ color = color,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 12.sp,
+ lineHeight = 16.sp,
+ modifier = Modifier.weight(1f).padding(vertical = 5.dp),
+ )
+ if (line.source.isNotBlank()) {
+ Spacer(Modifier.width(8.dp))
+ Text(
+ line.source.substringAfterLast('/') + ":" + line.line,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontFamily = FontFamily.Monospace,
+ fontSize = 11.sp,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(vertical = 5.dp).width(96.dp),
+ )
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt
new file mode 100644
index 0000000000..c38bdfbc2c
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.VerticalDivider
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_close
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_count
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_hint
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_next
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_none
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_previous
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/**
+ * Find in page as a floating capsule above the bottom edge: search glyph, the query, a match chip
+ * ("3 / 12", or "No matches" in the error tone), previous / next, and close. IME Search jumps to the next
+ * match. [active] is 0-based; [total] null means no search has run yet.
+ */
+@Composable
+fun FindInPagePill(
+ query: String,
+ onQueryChange: (String) -> Unit,
+ active: Int,
+ total: Int?,
+ onNext: (forward: Boolean) -> Unit,
+ onClose: () -> Unit,
+ modifier: Modifier = Modifier,
+ autoFocus: Boolean = true,
+) {
+ val focus = remember { FocusRequester() }
+ if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
+ val noMatches = query.isNotEmpty() && total == 0
+
+ Surface(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp),
+ shape = CircleShape,
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shadowElevation = 8.dp,
+ border = PillDefaults.hairline(),
+ ) {
+ Row(Modifier.height(56.dp).padding(start = 18.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) {
+ Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (noMatches) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(12.dp))
+ Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) {
+ if (query.isEmpty()) {
+ Text(stringRes(Res.string.browser_pill_find_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = onQueryChange,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+ keyboardActions = KeyboardActions(onSearch = { onNext(true) }),
+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
+ )
+ }
+ if (query.isNotEmpty() && total != null) {
+ Spacer(Modifier.width(8.dp))
+ Box(
+ Modifier
+ .clip(CircleShape)
+ .background(if (noMatches) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer)
+ .padding(horizontal = 10.dp, vertical = 4.dp),
+ ) {
+ Text(
+ if (noMatches) stringRes(Res.string.browser_pill_find_none) else stringRes(Res.string.browser_pill_find_count, active + 1, total),
+ style = MaterialTheme.typography.labelMedium,
+ color = if (noMatches) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ }
+ }
+ IconButton(onClick = { onNext(false) }, enabled = (total ?: 0) > 0) {
+ Icon(MaterialSymbols.KeyboardArrowUp, contentDescription = stringRes(Res.string.browser_pill_find_previous))
+ }
+ IconButton(onClick = { onNext(true) }, enabled = (total ?: 0) > 0) {
+ Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = stringRes(Res.string.browser_pill_find_next))
+ }
+ VerticalDivider(Modifier.height(24.dp), color = MaterialTheme.colorScheme.outlineVariant)
+ IconButton(onClick = onClose) {
+ Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_find_close))
+ }
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt
new file mode 100644
index 0000000000..8c28a74bc7
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt
@@ -0,0 +1,476 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.ImeAction
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_cancel
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allow
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_ask
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_block
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_answer
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_block
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_generic
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_message
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_title
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_says
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear_confirm
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_permissions
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_allow
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_camera_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_deny
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_location_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone_desc
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/** A compact, read-only origin badge for card headers: the page can't fake it, so every card starts with it. */
+@Composable
+fun OriginBadge(
+ host: String,
+ security: BrowserChrome.Security,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .padding(start = 10.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SecurityIcon(security, size = 16.dp)
+ Spacer(Modifier.width(6.dp))
+ Text(host, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+}
+
+/** The card frame every page-initiated prompt shares. */
+@Composable
+private fun PageCard(content: @Composable () -> Unit) {
+ Surface(
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surfaceContainerHigh,
+ shadowElevation = 6.dp,
+ border = PillDefaults.hairline(),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Column(Modifier.padding(24.dp)) { content() }
+ }
+}
+
+/**
+ * A site asking for camera / microphone / location: the origin badge, a tinted circle per thing asked with
+ * what it means, a note when calls over Tor could still expose the IP, and three plain-worded answers —
+ * remembered allow, one-time allow, remembered refusal.
+ */
+@Composable
+fun PermissionPromptCard(
+ host: String,
+ security: BrowserChrome.Security,
+ permissions: Set,
+ onAllow: () -> Unit,
+ onAllowOnce: () -> Unit,
+ onDeny: () -> Unit,
+) {
+ PageCard {
+ OriginBadge(host, security)
+ Spacer(Modifier.height(20.dp))
+ Text(
+ stringRes(Res.string.browser_pill_perm_title),
+ style = MaterialTheme.typography.titleLarge,
+ )
+ Spacer(Modifier.height(16.dp))
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ permissions.sortedBy { it.ordinal }.forEach { permission ->
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(
+ Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true)
+ }
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.titleSmall)
+ Text(
+ stringRes(
+ when (permission) {
+ BrowserSitePermission.CAMERA -> Res.string.browser_pill_perm_camera_desc
+ BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_perm_microphone_desc
+ BrowserSitePermission.LOCATION -> Res.string.browser_pill_perm_location_desc
+ },
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+ // WebRTC can reach out around the SOCKS proxy; say so where it matters, without blocking the call.
+ if (security == BrowserChrome.Security.TOR && (BrowserSitePermission.CAMERA in permissions || BrowserSitePermission.MICROPHONE in permissions)) {
+ Spacer(Modifier.height(16.dp))
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f))
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PillActionIcon(BrowserChrome.Action.TOR, tint = MaterialTheme.colorScheme.onTertiaryContainer, size = 18.dp)
+ Spacer(Modifier.width(10.dp))
+ Text(stringRes(Res.string.browser_pill_perm_tor_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer)
+ }
+ }
+ Spacer(Modifier.height(24.dp))
+ Button(onClick = onAllow, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_allow)) }
+ Spacer(Modifier.height(8.dp))
+ FilledTonalButton(onClick = onAllowOnce, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_once)) }
+ Spacer(Modifier.height(4.dp))
+ TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_deny)) }
+ }
+}
+
+/** The kinds of page dialog. */
+enum class PageDialogType { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD }
+
+/**
+ * A page's alert / confirm / prompt / beforeunload. The origin badge heads it (so it can't pass for
+ * Amethyst UI); prompt gets a labelled field with clear and IME Done; repeat dialogs offer a checkbox to
+ * block the rest, as Chrome does.
+ */
+@Composable
+fun PageDialogCard(
+ type: PageDialogType,
+ host: String?,
+ security: BrowserChrome.Security,
+ message: String,
+ defaultValue: String = "",
+ offerBlock: Boolean = false,
+ onResult: (confirmed: Boolean, text: String?, block: Boolean) -> Unit,
+ autoFocus: Boolean = true,
+) {
+ var text by remember { mutableStateOf(defaultValue) }
+ var block by remember { mutableStateOf(false) }
+ val leave = type == PageDialogType.BEFORE_UNLOAD
+ val focus = remember { FocusRequester() }
+ if (type == PageDialogType.PROMPT && autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
+
+ PageCard {
+ if (host != null) {
+ OriginBadge(host, security)
+ Spacer(Modifier.height(16.dp))
+ }
+ Text(
+ when {
+ leave -> stringRes(Res.string.browser_pill_dialog_leave_title)
+ host != null -> stringRes(Res.string.browser_pill_dialog_says, host)
+ else -> stringRes(Res.string.browser_pill_dialog_generic)
+ },
+ style = MaterialTheme.typography.headlineSmall,
+ )
+ Spacer(Modifier.height(12.dp))
+ Column(Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState())) {
+ Text(
+ if (leave) stringRes(Res.string.browser_pill_dialog_leave_message) else message,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ if (type == PageDialogType.PROMPT) {
+ Spacer(Modifier.height(16.dp))
+ OutlinedTextField(
+ value = text,
+ onValueChange = { text = it },
+ label = { Text(stringRes(Res.string.browser_pill_dialog_answer)) },
+ singleLine = true,
+ trailingIcon = {
+ if (text.isNotEmpty()) {
+ IconButton(onClick = { text = "" }) { Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear)) }
+ }
+ },
+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { onResult(true, text, block) }),
+ shape = RoundedCornerShape(16.dp),
+ modifier = Modifier.fillMaxWidth().focusRequester(focus),
+ )
+ }
+ if (offerBlock) {
+ Spacer(Modifier.height(8.dp))
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .clickable { block = !block }
+ .padding(vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Checkbox(checked = block, onCheckedChange = { block = it })
+ Text(stringRes(Res.string.browser_pill_dialog_block), style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ Spacer(Modifier.height(20.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ if (type != PageDialogType.ALERT) {
+ TextButton(onClick = { onResult(false, null, block) }) { Text(stringRes(Res.string.browser_pill_cancel)) }
+ Spacer(Modifier.width(8.dp))
+ }
+ Button(
+ onClick = { onResult(true, if (type == PageDialogType.PROMPT) text else null, block) },
+ colors = if (leave) ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError) else ButtonDefaults.buttonColors(),
+ ) {
+ Text(stringRes(if (leave) Res.string.browser_pill_dialog_leave else Res.string.browser_pill_ok))
+ }
+ }
+ }
+}
+
+/** Certificate facts for [PageInfoSheet]. */
+data class CertificateInfo(
+ val issuedTo: String,
+ val issuedBy: String,
+ val validUntil: String,
+)
+
+/**
+ * Page info as a sheet: who the site is, how you're connected (encryption, route, certificate), what it
+ * may use (camera / mic / location as Ask · Allow · Block, editable in place), and its stored data with a
+ * clear that asks first.
+ */
+@Composable
+fun PageInfoSheet(
+ ui: BrowserPillUi,
+ certificate: CertificateInfo?,
+ onPermissionChange: (BrowserSitePermission, Decision) -> Unit,
+ onClearSiteData: () -> Unit,
+ modifier: Modifier = Modifier,
+ initiallyConfirmingClear: Boolean = false,
+) {
+ var confirmingClear by remember { mutableStateOf(initiallyConfirmingClear) }
+ val https = ui.chrome.url.startsWith("https://", ignoreCase = true)
+ Surface(
+ modifier = modifier.fillMaxWidth(),
+ shape = RoundedCornerShape(28.dp),
+ color = MaterialTheme.colorScheme.surface,
+ shadowElevation = 6.dp,
+ border = PillDefaults.hairline(),
+ ) {
+ Column(Modifier.padding(PillDefaults.SheetPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ SiteMonogram(ui.title.ifBlank { ui.host }, size = 48.dp)
+ Spacer(Modifier.width(14.dp))
+ Column {
+ Text(ui.host, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(BrowserChrome.originOf(ui.chrome.url) ?: ui.chrome.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+
+ GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) {
+ GroupRow(
+ icon = { Icon(if (https) MaterialSymbols.Lock else MaterialSymbols.NoEncryption, contentDescription = null, tint = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onErrorContainer, filled = true) },
+ iconContainer = if (https) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.errorContainer,
+ title = stringRes(if (https) Res.string.browser_pill_info_https else Res.string.browser_pill_info_http),
+ supporting = stringRes(if (https) Res.string.browser_pill_info_https_desc else Res.string.browser_pill_info_http_desc),
+ supportingColor = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error,
+ onClick = null,
+ )
+ ui.chrome.torOn?.let { tor ->
+ GroupDivider()
+ GroupRow(
+ icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
+ iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
+ title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open),
+ supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
+ onClick = null,
+ )
+ }
+ if (certificate != null) {
+ GroupDivider()
+ GroupRow(
+ icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
+ iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest,
+ title = stringRes(Res.string.browser_pill_info_certificate),
+ supporting = stringRes(Res.string.browser_pill_info_certificate_desc, certificate.issuedTo, certificate.issuedBy, certificate.validUntil),
+ onClick = null,
+ )
+ }
+ }
+
+ GroupCard(heading = stringRes(Res.string.browser_pill_info_permissions)) {
+ BrowserSitePermission.entries.forEachIndexed { index, permission ->
+ if (index > 0) GroupDivider()
+ PermissionDecisionRow(permission, ui.sitePermissions[permission] ?: Decision.ASK) { onPermissionChange(permission, it) }
+ }
+ }
+
+ GroupCard(heading = stringRes(Res.string.browser_pill_info_site_data)) {
+ Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(MaterialSymbols.Cookie, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.width(12.dp))
+ Text(stringRes(Res.string.browser_pill_info_site_data_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.height(12.dp))
+ AnimatedContent(targetState = confirmingClear, label = "clear-site-data") { confirming ->
+ if (!confirming) {
+ OutlinedButton(
+ onClick = { confirmingClear = true },
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp))
+ Spacer(Modifier.width(8.dp))
+ Text(stringRes(Res.string.browser_pill_info_clear))
+ }
+ } else {
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(MaterialTheme.colorScheme.errorContainer)
+ .padding(12.dp),
+ ) {
+ Text(stringRes(Res.string.browser_pill_info_clear_confirm), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer)
+ Spacer(Modifier.height(8.dp))
+ Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
+ TextButton(onClick = { confirmingClear = false }) { Text(stringRes(Res.string.browser_pill_cancel), color = MaterialTheme.colorScheme.onErrorContainer) }
+ Spacer(Modifier.width(8.dp))
+ Button(
+ onClick = {
+ confirmingClear = false
+ onClearSiteData()
+ },
+ colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError),
+ ) { Text(stringRes(Res.string.browser_pill_info_clear)) }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** One permission with a first-class Ask · Allow · Block choice. */
+@Composable
+private fun PermissionDecisionRow(
+ permission: BrowserSitePermission,
+ decision: Decision,
+ onChange: (Decision) -> Unit,
+) {
+ Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) {
+ Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.width(12.dp))
+ Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
+ }
+ Spacer(Modifier.height(8.dp))
+ val options = listOf(Decision.ASK to Res.string.browser_pill_decision_ask, Decision.ALLOW to Res.string.browser_pill_decision_allow, Decision.BLOCK to Res.string.browser_pill_decision_block)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(start = 52.dp)) {
+ options.forEachIndexed { index, (option, label) ->
+ SegmentedButton(
+ selected = decision == option,
+ onClick = { onChange(option) },
+ shape = SegmentedButtonDefaults.itemShape(index, options.size),
+ icon = {},
+ label = { Text(stringRes(label), textAlign = TextAlign.Center, maxLines = 1) },
+ )
+ }
+ }
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt
new file mode 100644
index 0000000000..1dc373369b
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt
@@ -0,0 +1,348 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address
+import com.vitorpamplona.amethyst.commons.ui.stringRes
+
+/** Shapes and sizes shared by every pill surface, so they read as one family. */
+object PillDefaults {
+ val SheetShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp)
+ val CardShape = RoundedCornerShape(20.dp)
+ val TileShape = RoundedCornerShape(18.dp)
+ val SheetPadding = 16.dp
+ val TileHeight = 88.dp
+ val FieldHeight = 48.dp
+
+ /** The edge every floating pill surface draws, so it stays visible where shadows don't (black theme). */
+ @Composable
+ fun hairline(): BorderStroke = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f))
+}
+
+/**
+ * A site's stand-in icon: its first letter on a tonal rounded square. Real favicons can drop in later
+ * (the launcher already stores them per host); the monogram keeps the header from ever being empty.
+ */
+@Composable
+fun SiteMonogram(
+ label: String,
+ modifier: Modifier = Modifier,
+ size: Dp = 40.dp,
+) {
+ val letter = label.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "•"
+ Box(
+ modifier
+ .size(size)
+ .clip(RoundedCornerShape(size * 0.3f))
+ .background(MaterialTheme.colorScheme.primaryContainer),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ letter,
+ style = if (size >= 40.dp) MaterialTheme.typography.titleMedium else MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimaryContainer,
+ )
+ }
+}
+
+/**
+ * The origin as a read-only field: security badge in its signal colour, host, and a trailing pencil that
+ * says "this is where the address lives". Tapping edits the address; long-pressing copies the link.
+ * Sandboxed apps get a non-editable version with no pencil.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun OriginField(
+ ui: BrowserPillUi,
+ onEdit: (() -> Unit)?,
+ onLongPress: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+) {
+ val security = ui.security
+ Row(
+ modifier
+ .fillMaxWidth()
+ .heightIn(min = PillDefaults.FieldHeight)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHighest)
+ .combinedClickable(
+ enabled = onEdit != null || onLongPress != null,
+ role = Role.Button,
+ onClick = { onEdit?.invoke() },
+ onLongClick = onLongPress,
+ ).padding(start = 16.dp, end = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ SecurityIcon(security, size = 18.dp)
+ Spacer(Modifier.width(10.dp))
+ Column(Modifier.weight(1f).padding(vertical = 6.dp)) {
+ Text(
+ if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ // Only the states worth a second look get words: plain HTTP and Tor.
+ if (!ui.chrome.isSandbox && (security == BrowserChrome.Security.HTTP || security == BrowserChrome.Security.TOR)) {
+ Text(
+ stringRes(securityLabel(security)),
+ style = MaterialTheme.typography.labelSmall,
+ color = securityTint(security),
+ maxLines = 1,
+ )
+ }
+ }
+ if (onEdit != null) {
+ IconButton(onClick = onEdit) {
+ Icon(MaterialSymbols.Edit, contentDescription = stringRes(Res.string.browser_pill_edit_address), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
+ Spacer(Modifier.width(10.dp))
+ }
+ }
+}
+
+/**
+ * The navigation capsule: Chrome's back · forward · reload/stop · star · share on one rounded bar. Back
+ * and forward dim when unavailable; the star fills when pinned; reload turns into stop, ringed by the load.
+ */
+@Composable
+fun NavigationCapsule(
+ ui: BrowserPillUi,
+ onAction: (Action) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ BrowserChrome.iconRow(ui.chrome).forEach { action ->
+ val enabled = BrowserChrome.isEnabled(ui.chrome, action)
+ val pinned = action == Action.FAVORITE && ui.isFavorite
+ IconButton(
+ onClick = { onAction(action) },
+ enabled = enabled,
+ colors = IconButtonDefaults.iconButtonColors(disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)),
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ if (action == Action.STOP && ui.loadProgress != null) {
+ CircularProgressIndicator(
+ progress = { ui.loadProgress },
+ modifier = Modifier.size(34.dp),
+ strokeWidth = 2.dp,
+ trackColor = Color.Transparent,
+ )
+ }
+ PillActionIcon(
+ action = action,
+ tint =
+ when {
+ !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
+ pinned -> MaterialTheme.colorScheme.primary
+ else -> MaterialTheme.colorScheme.onSurface
+ },
+ size = if (action == Action.STOP) 20.dp else 24.dp,
+ filled = pinned,
+ contentDescription = stringRes(pillLabelFor(action, ui.isFavorite)),
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * A page-action tile: icon over a two-line label on a rounded card. A toggle tile ([selected] non-null)
+ * fills with the secondary container while on, so its state reads without a switch.
+ */
+@Composable
+fun ActionTile(
+ symbol: MaterialSymbol,
+ label: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ selected: Boolean? = null,
+ description: String = label,
+) {
+ val on = selected == true
+ Surface(
+ onClick = onClick,
+ modifier = modifier.height(PillDefaults.TileHeight).semantics(mergeDescendants = true) { contentDescription = description },
+ shape = PillDefaults.TileShape,
+ color = if (on) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer,
+ contentColor = if (on) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface,
+ ) {
+ Column(
+ // Top-aligned so every icon in a row sits on one line, whether its label takes one line or two.
+ Modifier.padding(start = 4.dp, end = 4.dp, top = 16.dp, bottom = 8.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top,
+ ) {
+ Icon(symbol, contentDescription = null, modifier = Modifier.size(24.dp), filled = on)
+ Spacer(Modifier.height(6.dp))
+ Text(
+ label,
+ style = MaterialTheme.typography.labelMedium,
+ textAlign = TextAlign.Center,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+/** A rounded group of rows (the privacy card), with an optional heading above it. */
+@Composable
+fun GroupCard(
+ heading: String?,
+ modifier: Modifier = Modifier,
+ content: @Composable ColumnScope.() -> Unit,
+) {
+ Column(modifier.fillMaxWidth()) {
+ if (heading != null) {
+ Text(
+ heading,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 12.dp, bottom = 6.dp),
+ )
+ }
+ Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) {
+ Column(Modifier.padding(vertical = 4.dp), content = content)
+ }
+ }
+}
+
+/** A divider inset past a [GroupRow]'s leading icon. */
+@Composable
+fun GroupDivider() {
+ HorizontalDivider(Modifier.padding(start = 64.dp, end = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
+}
+
+/**
+ * One row of a [GroupCard]: a tinted icon circle, a title with a supporting line that states the
+ * consequence, and a trailing control (switch, chevron, badge).
+ */
+@Composable
+fun GroupRow(
+ icon: @Composable () -> Unit,
+ iconContainer: Color,
+ title: String,
+ supporting: String?,
+ onClick: (() -> Unit)?,
+ modifier: Modifier = Modifier,
+ supportingColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
+ trailing: @Composable RowScope.() -> Unit = {},
+) {
+ Row(
+ modifier
+ .fillMaxWidth()
+ .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier)
+ .padding(horizontal = 12.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(Modifier.size(40.dp).clip(CircleShape).background(iconContainer), contentAlignment = Alignment.Center) { icon() }
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ if (supporting != null) {
+ Text(supporting, style = MaterialTheme.typography.bodySmall, color = supportingColor, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ Spacer(Modifier.width(8.dp))
+ trailing()
+ }
+}
+
+/** A small count badge (console errors). */
+@Composable
+fun CountBadge(
+ count: Int,
+ container: Color = MaterialTheme.colorScheme.errorContainer,
+ content: Color = MaterialTheme.colorScheme.onErrorContainer,
+) {
+ Box(
+ Modifier
+ .heightIn(min = 22.dp)
+ .clip(CircleShape)
+ .background(container)
+ .padding(horizontal = 8.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(if (count > 99) "99+" else count.toString(), style = MaterialTheme.typography.labelMedium, color = content)
+ }
+}
+
+/** Dims content that is shown but not usable. */
+fun Modifier.dimmed(enabled: Boolean): Modifier = if (enabled) this else this.alpha(0.38f)
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt
new file mode 100644
index 0000000000..abcb5c8ac7
--- /dev/null
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.graphics.vector.PathParser
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action
+import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security
+import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission
+import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
+import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
+import com.vitorpamplona.amethyst.commons.resources.Res
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_access
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_add_home
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_back
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_console
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_copy
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_desktop
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_add
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_remove
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_find
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_forward
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_full_screen
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_other_browser
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_camera
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_location
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_microphone
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_reload
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_http
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_https
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_sandbox
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_tor
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_share
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_stop
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_size
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_copy
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_desktop
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_find
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_full
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_home
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_other
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_text
+import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_title
+import org.jetbrains.compose.resources.StringResource
+import androidx.compose.material3.Icon as Material3Icon
+
+/** Tor's onion, as a vector (the same artwork as the Android `ic_tor` drawable). */
+val OnionIcon: ImageVector by lazy {
+ ImageVector
+ .Builder(name = "Onion", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f)
+ .addPath(pathData = PathParser().parsePathString(ONION_PATH).toNodes(), fill = SolidColor(Color.Black))
+ .build()
+}
+
+private const val ONION_PATH = "M17.578,12.201c-0.76,-0.692 -1.721,-1.251 -2.704,-1.81 -0.446,-0.246 -1.81,-1.318 -1.34,-2.838l-0.851,-0.358c1.342,-2.078 3.085,-4.134 5.229,-6.056 -1.721,0.581 -3.24,1.476 -4.379,3.062 0.67,-1.407 1.765,-2.793 2.972,-4.201 -1.654,1.185 -3.084,2.525 -3.979,4.313l0.627,-2.503c-0.894,1.608 -1.52,3.24 -1.766,4.871l-1.317,-0.535 -0.223,0.178c1.162,2.078 0.559,3.174 -0.022,3.553 -1.162,0.783 -2.838,1.788 -3.688,2.659 -1.609,1.654 -2.078,3.218 -1.921,5.296 0.157,2.66 2.101,4.873 4.67,5.744 1.14,0.38 2.19,0.424 3.352,0.424 1.877,0 3.799,-0.491 5.207,-1.676a6.551,6.551 0,0 0,2.369 -5.027,6.875 6.875,0 0,0 -2.236,-5.096zM14.025,21.073c-0.09,0.402 -0.38,0.894 -0.737,1.341 0.134,-0.246 0.246,-0.492 0.313,-0.76 0.559,-1.989 0.805,-2.904 0.537,-5.095 -0.045,-0.224 -0.135,-0.938 -0.471,-1.721 -0.468,-1.185 -1.184,-2.303 -1.272,-2.548 -0.157,-0.38 -0.38,-1.989 -0.403,-3.084 0.023,0.938 0.089,2.659 0.335,3.329 0.067,0.225 0.715,1.229 1.185,2.459 0.312,0.849 0.38,1.632 0.446,1.854 0.224,1.007 -0.045,2.705 -0.401,4.313 -0.111,0.581 -0.426,1.252 -0.828,1.766 0.225,-0.313 0.402,-0.715 0.537,-1.185 0.269,-0.938 0.38,-2.145 0.356,-2.905 -0.021,-0.446 -0.222,-1.407 -0.558,-2.278 -0.201,-0.47 -0.492,-0.961 -0.692,-1.297 -0.224,-0.335 -0.224,-1.072 -0.313,-1.921 0.021,0.916 -0.068,1.385 0.156,2.033 0.134,0.379 0.625,0.916 0.759,1.43 0.201,0.693 0.402,1.453 0.381,1.922 0,0.536 -0.022,1.52 -0.269,2.593 -0.157,0.804 -0.515,1.497 -1.095,1.943 0.246,-0.312 0.38,-0.625 0.447,-0.938 0.089,-0.469 0.111,-0.916 0.156,-1.475a5.96,5.96 0,0 0,-0.111 -1.721c-0.179,-0.805 -0.469,-1.608 -0.604,-2.168 0.022,0.626 0.269,1.408 0.381,2.235 0.089,0.604 0.044,1.206 0.021,1.742 -0.021,0.627 -0.223,1.722 -0.492,2.258 -0.268,-0.112 -0.357,-0.269 -0.537,-0.491 -0.223,-0.291 -0.357,-0.604 -0.491,-0.962a5.043,5.043 0,0 1,-0.291 -0.915,3.071 3.071,0 0,1 0.559,-2.213c0.469,-0.671 0.559,-0.716 0.715,-1.497 -0.223,0.692 -0.379,0.759 -0.871,1.341 -0.559,0.647 -0.648,1.586 -0.648,2.346 0,0.313 0.134,0.671 0.246,1.007 0.134,0.356 0.268,0.714 0.447,0.982 0.134,0.223 0.313,0.379 0.469,0.491 -0.581,-0.156 -1.184,-0.379 -1.564,-0.692 -0.938,-0.805 -1.765,-2.167 -1.877,-3.375 -0.089,-0.982 0.804,-2.413 2.078,-3.128 1.073,-0.626 1.318,-1.319 1.542,-2.459 -0.313,0.983 -0.626,1.833 -1.654,2.348 -1.475,0.804 -2.235,2.1 -2.167,3.352 0.112,1.586 0.737,2.682 2.011,3.554 0.291,0.2 0.693,0.401 1.118,0.559 -1.587,-0.381 -1.788,-0.604 -2.324,-1.229 0,-0.045 -0.134,-0.135 -0.134,-0.156 -0.715,-0.805 -1.609,-2.19 -1.922,-3.464 -0.112,-0.447 -0.224,-0.916 -0.089,-1.363 0.581,-2.101 1.854,-2.905 3.128,-3.775 0.313,-0.225 0.626,-0.426 0.916,-0.649 0.715,-0.559 0.894,-2.012 1.05,-2.838 -0.29,1.006 -0.603,2.258 -1.162,2.659 -0.29,0.224 -0.648,0.402 -0.938,0.604 -1.318,0.894 -2.637,1.743 -3.24,3.91 -0.134,0.56 -0.044,0.962 0.089,1.498 0.335,1.317 1.229,2.748 1.989,3.597l0.134,0.135c0.335,0.381 0.76,0.67 1.274,0.871a5.945,5.945 0,0 1,-1.296 -0.469c-2.078,-1.005 -3.463,-3.173 -3.553,-4.939 -0.179,-3.597 1.542,-4.647 3.151,-5.966 0.894,-0.737 2.145,-1.095 2.86,-2.413 0.134,-0.291 0.224,-0.916 0.045,-1.587 -0.067,-0.224 -0.402,-1.028 -0.537,-1.207l1.989,0.872c-0.044,0.938 -0.067,1.698 0.112,2.391 0.2,0.76 1.184,1.854 1.586,3.129 0.783,2.41 0.583,5.561 0.023,8.019z"
+
+/** The glyph for [action]; null means it's drawn with [OnionIcon]. */
+fun pillSymbolFor(action: Action): MaterialSymbol? =
+ when (action) {
+ Action.BACK -> MaterialSymbols.AutoMirrored.ArrowBack
+ Action.FORWARD -> MaterialSymbols.AutoMirrored.ArrowForward
+ Action.RELOAD -> MaterialSymbols.Refresh
+ Action.STOP -> MaterialSymbols.Close
+ Action.FAVORITE -> MaterialSymbols.Star
+ Action.SHARE -> MaterialSymbols.Share
+ Action.BACK_TO_APP -> MaterialSymbols.Home
+ Action.COPY_LINK -> MaterialSymbols.ContentCopy
+ Action.EDIT_ADDRESS -> MaterialSymbols.Edit
+ Action.FIND_IN_PAGE -> MaterialSymbols.Search
+ Action.TEXT_SIZE -> MaterialSymbols.FormatSize
+ Action.DESKTOP_SITE -> MaterialSymbols.DesktopWindows
+ Action.ADD_TO_HOME_SCREEN -> MaterialSymbols.AddToHomeScreen
+ Action.OPEN_IN_BROWSER_APP -> MaterialSymbols.OpenInBrowser
+ Action.OPEN_FULL_SCREEN -> MaterialSymbols.OpenInFull
+ Action.TOR -> null
+ Action.ACCESS_INFO -> MaterialSymbols.Shield
+ Action.SITE_SETTINGS -> MaterialSymbols.Tune
+ Action.CONSOLE -> MaterialSymbols.Code
+ }
+
+fun pillLabelFor(
+ action: Action,
+ isFavorite: Boolean = false,
+): StringResource =
+ when (action) {
+ Action.BACK -> Res.string.browser_pill_back
+ Action.FORWARD -> Res.string.browser_pill_forward
+ Action.RELOAD -> Res.string.browser_pill_reload
+ Action.STOP -> Res.string.browser_pill_stop
+ Action.FAVORITE -> if (isFavorite) Res.string.browser_pill_favorite_remove else Res.string.browser_pill_favorite_add
+ Action.SHARE -> Res.string.browser_pill_share
+ Action.BACK_TO_APP -> Res.string.browser_pill_back_to_app
+ Action.COPY_LINK -> Res.string.browser_pill_copy
+ Action.EDIT_ADDRESS -> Res.string.browser_pill_edit_address
+ Action.FIND_IN_PAGE -> Res.string.browser_pill_find
+ Action.TEXT_SIZE -> Res.string.browser_pill_text_size
+ Action.DESKTOP_SITE -> Res.string.browser_pill_desktop
+ Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_add_home
+ Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_other_browser
+ Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_full_screen
+ Action.TOR -> Res.string.browser_pill_tor_title
+ Action.ACCESS_INFO -> Res.string.browser_pill_access
+ Action.SITE_SETTINGS -> Res.string.browser_pill_site_settings
+ Action.CONSOLE -> Res.string.browser_pill_console
+ }
+
+/**
+ * The short label a page-action tile shows under its icon (tiles are ~88dp wide); the full
+ * [pillLabelFor] wording stays the accessibility description.
+ */
+fun pillTileLabelFor(action: Action): StringResource =
+ when (action) {
+ Action.COPY_LINK -> Res.string.browser_pill_tile_copy
+ Action.FIND_IN_PAGE -> Res.string.browser_pill_tile_find
+ Action.TEXT_SIZE -> Res.string.browser_pill_tile_text
+ Action.DESKTOP_SITE -> Res.string.browser_pill_tile_desktop
+ Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_tile_home
+ Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_tile_other
+ Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_tile_full
+ else -> pillLabelFor(action)
+ }
+
+fun securityLabel(security: Security): StringResource =
+ when (security) {
+ Security.TOR -> Res.string.browser_pill_security_tor
+ Security.HTTPS -> Res.string.browser_pill_security_https
+ Security.HTTP -> Res.string.browser_pill_security_http
+ Security.SANDBOX -> Res.string.browser_pill_security_sandbox
+ }
+
+fun permissionLabel(permission: BrowserSitePermission): StringResource =
+ when (permission) {
+ BrowserSitePermission.CAMERA -> Res.string.browser_pill_permission_camera
+ BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_permission_microphone
+ BrowserSitePermission.LOCATION -> Res.string.browser_pill_permission_location
+ }
+
+fun permissionSymbol(permission: BrowserSitePermission): MaterialSymbol =
+ when (permission) {
+ BrowserSitePermission.CAMERA -> MaterialSymbols.Videocam
+ BrowserSitePermission.MICROPHONE -> MaterialSymbols.Mic
+ BrowserSitePermission.LOCATION -> MaterialSymbols.LocationOn
+ }
+
+/** The colour that signals [security]: the error tone for plain HTTP, the Tor accent for onion routing. */
+@Composable
+fun securityTint(security: Security): Color =
+ when (security) {
+ Security.HTTP -> MaterialTheme.colorScheme.error
+ Security.TOR -> MaterialTheme.colorScheme.tertiary
+ Security.SANDBOX -> MaterialTheme.colorScheme.primary
+ Security.HTTPS -> MaterialTheme.colorScheme.onSurfaceVariant
+ }
+
+/** Draws [action]'s icon (the onion for Tor). */
+@Composable
+fun PillActionIcon(
+ action: Action,
+ tint: Color,
+ size: Dp = 24.dp,
+ filled: Boolean = false,
+ contentDescription: String? = null,
+) {
+ val symbol = pillSymbolFor(action)
+ if (symbol != null) {
+ Icon(symbol, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint, filled = filled)
+ } else {
+ Material3Icon(OnionIcon, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint)
+ }
+}
+
+/** Draws the badge for [security] in its signal colour. */
+@Composable
+fun SecurityIcon(
+ security: Security,
+ size: Dp = 18.dp,
+ tint: Color = securityTint(security),
+) {
+ when (security) {
+ Security.TOR -> Material3Icon(OnionIcon, contentDescription = null, modifier = Modifier.size(size), tint = tint)
+ Security.HTTPS -> Icon(MaterialSymbols.Lock, contentDescription = null, modifier = Modifier.size(size), tint = tint)
+ Security.HTTP -> Icon(MaterialSymbols.NoEncryption, contentDescription = null, modifier = Modifier.size(size), tint = tint)
+ Security.SANDBOX -> Icon(MaterialSymbols.Shield, contentDescription = null, modifier = Modifier.size(size), tint = tint, filled = true)
+ }
+}
diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
index 595b7cf845..6920115b06 100644
--- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
+++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt
@@ -77,11 +77,13 @@ object MaterialSymbols {
val CloudSync = MaterialSymbol("\uEB5A")
val CloudUpload = MaterialSymbol("\uE2C3")
val Code = MaterialSymbol("\uE86F")
+ val Cookie = MaterialSymbol("\uEAAC")
val Collections = MaterialSymbol("\uE3D3")
val CollectionsBookmark = MaterialSymbol("\uE431")
val Commit = MaterialSymbol("\uEAF5")
val ContentCopy = MaterialSymbol("\uE14D")
val ContentPaste = MaterialSymbol("\uE14F")
+ val ContentPasteGo = MaterialSymbol("\uEA8E")
val CurrencyBitcoin = MaterialSymbol("\uEBC5")
val Dashboard = MaterialSymbol("\uE871")
val DateRange = MaterialSymbol("\uE916")
@@ -173,6 +175,7 @@ object MaterialSymbols {
val News = MaterialSymbol("\uE032")
val NoAccounts = MaterialSymbol("\uF03E")
val NoEncryption = MaterialSymbol("\uF03F")
+ val NorthWest = MaterialSymbol("\uF1E2", autoMirror = true)
val Notifications = MaterialSymbol("\uE7F5")
val NotificationsOff = MaterialSymbol("\uE7F6")
val Numbers = MaterialSymbol("\uEAC7")
diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt
new file mode 100644
index 0000000000..3ac0d504e9
--- /dev/null
+++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt
@@ -0,0 +1,96 @@
+/*
+ * Copyright (c) 2025 Vitor Pamplona
+ *
+ * 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.
+ */
+package com.vitorpamplona.amethyst.commons.browser.ui.pill
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.unit.Density
+import org.jetbrains.skia.EncodedImageFormat
+import java.io.File
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+/**
+ * Renders every browser-pill prototype offscreen (no device, no display) through `ImageComposeScene`, in the
+ * dark and light Amethyst themes side by side, and writes each to `commonsUI/build/browser-pill/.png`.
+ *
+ * It exists so the redesign can be *looked at* while it's built — see
+ * `amethyst/plans/2026-09-26-browser-ui-review.md` — and it fails if a screen throws or draws nothing.
+ */
+class BrowserPillRenderTest {
+ private val outDir = File("build/browser-pill").apply { mkdirs() }
+
+ private fun render(
+ name: String,
+ widthDp: Int,
+ heightDp: Int,
+ content: @Composable () -> Unit,
+ ) {
+ val density = 2f
+ val width = (widthDp * density).toInt()
+ val height = (heightDp * density).toInt()
+ val scene = ImageComposeScene(width = width, height = height, density = Density(density), content = content)
+ try {
+ // Strings and fonts load asynchronously from compose resources: let a few frames settle.
+ var image = scene.render(0)
+ repeat(SETTLE_FRAMES) { frame ->
+ Thread.sleep(FRAME_MILLIS)
+ image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L)
+ }
+ val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode $name")
+ File(outDir, "$name.png").writeBytes(png.bytes)
+
+ val pixels = image.peekPixels() ?: error("no pixels for $name")
+ val distinct = HashSet()
+ for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y)
+ assertTrue(distinct.size > 20, "$name rendered almost nothing (${distinct.size} colours)")
+ } finally {
+ scene.close()
+ }
+ }
+
+ @Test fun expanded() = render("01-expanded", 820, 1000) { BrowserPillExpandedPreview() }
+
+ @Test fun torOutOfScope() = render("02-tor-out-of-scope", 820, 1100) { BrowserPillTorOutOfScopePreview() }
+
+ @Test fun addressEditor() = render("03-address-editor", 820, 700) { BrowserPillAddressEditorPreview() }
+
+ @Test fun napplet() = render("04-napplet", 820, 800) { BrowserPillNappletPreview() }
+
+ @Test fun handles() = render("05-handles", 820, 160) { PillHandlesPreview() }
+
+ @Test fun find() = render("06-find", 820, 220) { FindInPagePreview() }
+
+ @Test fun console() = render("07-console", 820, 380) { ConsoleSheetPreview() }
+
+ @Test fun permission() = render("08-permission", 820, 700) { PermissionPromptPreview() }
+
+ @Test fun dialog() = render("09-dialog", 820, 560) { PageDialogPreview() }
+
+ @Test fun leave() = render("10-leave", 820, 420) { LeaveSiteDialogPreview() }
+
+ @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() }
+
+ private companion object {
+ const val SETTLE_FRAMES = 12
+ const val FRAME_MILLIS = 60L
+ }
+}