From b8ca114c150319cb9d5d2c2bfa02167b16977b3f Mon Sep 17 00:00:00 2001 From: Cody Lundquist Date: Sun, 15 Jun 2025 19:26:16 -0700 Subject: [PATCH 1/9] feat: Add Air Traffic Control for automatic link routing to workspaces, b=no-bug, c=workspaces --- .../base/content/zen-assets.jar.inc.mn | 2 + .../components/preferences/jar-mn.patch | 5 +- .../preferences/zen-air-traffic-control.js | 659 ++++++++ .../preferences/zen-preferences-links.xhtml | 6 +- .../components/preferences/zen-settings.js | 3 + .../preferences/zenTabsManagement.inc.xhtml | 157 +- .../modules/BrowserDOMWindow-sys-mjs.patch | 62 +- .../preferences/zen-air-traffic-control.css | 344 +++++ src/browser/themes/shared/zen-sources.inc.mn | 3 +- src/zen/common/ZenStartup.mjs | 25 + src/zen/modules/ZenAirTrafficControl.mjs | 165 ++ .../ZenAirTrafficControlIntegration.mjs | 115 ++ .../tests/air_traffic_control/browser.toml | 5 + .../browser_air_traffic_control_basic.js | 217 +++ .../browser_air_traffic_control_e2e.js | 1362 +++++++++++++++++ src/zen/tests/moz.build | 1 + src/zen/workspaces/ZenWorkspaces.mjs | 8 + 17 files changed, 3130 insertions(+), 9 deletions(-) create mode 100644 src/browser/components/preferences/zen-air-traffic-control.js create mode 100644 src/browser/themes/shared/preferences/zen-air-traffic-control.css create mode 100644 src/zen/modules/ZenAirTrafficControl.mjs create mode 100644 src/zen/modules/ZenAirTrafficControlIntegration.mjs create mode 100644 src/zen/tests/air_traffic_control/browser.toml create mode 100644 src/zen/tests/air_traffic_control/browser_air_traffic_control_basic.js create mode 100644 src/zen/tests/air_traffic_control/browser_air_traffic_control_e2e.js diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index c0b5488de4..cad0b01e75 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -52,6 +52,8 @@ content/browser/zen-styles/zen-gradient-generator.css (../../zen/workspaces/zen-gradient-generator.css) content/browser/zen-components/ZenKeyboardShortcuts.mjs (../../zen/kbs/ZenKeyboardShortcuts.mjs) + content/browser/zen/modules/ZenAirTrafficControl.mjs (../../zen/modules/ZenAirTrafficControl.mjs) + content/browser/zen/modules/ZenAirTrafficControlIntegration.mjs (../../zen/modules/ZenAirTrafficControlIntegration.mjs) content/browser/zen-components/ZenPinnedTabsStorage.mjs (../../zen/tabs/ZenPinnedTabsStorage.mjs) content/browser/zen-components/ZenPinnedTabManager.mjs (../../zen/tabs/ZenPinnedTabManager.mjs) diff --git a/src/browser/components/preferences/jar-mn.patch b/src/browser/components/preferences/jar-mn.patch index e2c5bec7ff..363681cf20 100644 --- a/src/browser/components/preferences/jar-mn.patch +++ b/src/browser/components/preferences/jar-mn.patch @@ -1,10 +1,11 @@ diff --git a/browser/components/preferences/jar.mn b/browser/components/preferences/jar.mn -index 118709048e7de13f6ac10d0047e446b72303428f..c8cc2d7ee551b96be668a7844dab1db5abc9d684 100644 +index 118709048e7de13f6ac10d0047e446b72303428f..1bed8626c2d7e1e08885a07aac777bec02f6e4dd 100644 --- a/browser/components/preferences/jar.mn +++ b/browser/components/preferences/jar.mn -@@ -26,3 +26,5 @@ browser.jar: +@@ -26,3 +26,6 @@ browser.jar: content/browser/preferences/widgets/setting-control.mjs (widgets/setting-control/setting-control.mjs) content/browser/preferences/widgets/setting-group.mjs (widgets/setting-group/setting-group.mjs) content/browser/preferences/widgets/setting-group.css (widgets/setting-group/setting-group.css) + + content/browser/preferences/zen-settings.js ++ content/browser/preferences/zen-air-traffic-control.js diff --git a/src/browser/components/preferences/zen-air-traffic-control.js b/src/browser/components/preferences/zen-air-traffic-control.js new file mode 100644 index 0000000000..d674e62046 --- /dev/null +++ b/src/browser/components/preferences/zen-air-traffic-control.js @@ -0,0 +1,659 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const { ZenAirTrafficControl } = ChromeUtils.importESModule( + "chrome://browser/content/zen/modules/ZenAirTrafficControl.mjs" +); + +var gZenAirTrafficControlPane = { + _workspaces: [], + _currentEditingRule: null, + + async init() { + ZenAirTrafficControl.init(); + + await this._loadWorkspaces(); + this._setupEventListeners(); + this._refreshRulesList(); + }, + + async _loadWorkspaces() { + try { + // Get browser window reference using ZenMultiWindowFeature (same as zen-settings.js) + const browserWin = ZenMultiWindowFeature.currentBrowser; + + if (browserWin && browserWin.gZenWorkspaces && browserWin.gZenWorkspaces._workspaces) { + // Load workspaces asynchronously + const workspacesData = await browserWin.gZenWorkspaces._workspaces(); + this._workspaces = workspacesData.workspaces || []; + this._populateWorkspaceDropdowns(); + } + } catch (e) { + console.error("Error loading workspaces:", e); + this._workspaces = []; + } + }, + + _populateWorkspaceDropdowns() { + // Populate new rule workspace dropdown + const newRuleWorkspace = document.getElementById("zenATCNewRuleWorkspace"); + if (newRuleWorkspace) { + this._populateWorkspaceDropdown(newRuleWorkspace); + } + }, + + _populateWorkspaceDropdown(dropdown, selectedWorkspaceId = null) { + const menupopup = dropdown.querySelector("menupopup"); + + // Clear existing items properly + while (menupopup.firstChild) { + menupopup.removeChild(menupopup.firstChild); + } + + if (this._workspaces.length === 0) { + const option = document.createXULElement("menuitem"); + option.setAttribute("value", ""); + option.setAttribute("label", "Loading workspaces..."); + option.setAttribute("disabled", "true"); + menupopup.appendChild(option); + return; + } + + // Add an option for a specific workspace if it's not in the list (fallback) + let hasSelectedWorkspace = false; + if (selectedWorkspaceId) { + hasSelectedWorkspace = this._workspaces.some(ws => ws.uuid === selectedWorkspaceId); + if (!hasSelectedWorkspace) { + const option = document.createXULElement("menuitem"); + option.setAttribute("value", selectedWorkspaceId); + option.setAttribute("label", `Workspace (${selectedWorkspaceId.substring(0, 8)}...)`); + menupopup.appendChild(option); + } + } + + for (const workspace of this._workspaces) { + const option = document.createXULElement("menuitem"); + option.setAttribute("value", workspace.uuid); + option.setAttribute("label", workspace.name); + menupopup.appendChild(option); + } + + // Select specific workspace or first workspace by default + if (selectedWorkspaceId) { + dropdown.value = selectedWorkspaceId; + // Force the dropdown to update its display + dropdown.setAttribute("value", selectedWorkspaceId); + } else if (this._workspaces.length > 0) { + dropdown.value = this._workspaces[0].uuid; + dropdown.setAttribute("value", this._workspaces[0].uuid); + } + }, + + _setupEventListeners() { + // Add rule button + document.getElementById("zenATCAddRuleButton").addEventListener("click", () => this._addRule()); + + // Import/Export buttons + document.getElementById("zenATCImportRules").addEventListener("click", () => this._importRules()); + document.getElementById("zenATCExportRules").addEventListener("click", () => this._exportRules()); + + // Match type change - show regex help + document.getElementById("zenATCNewRuleMatchType").addEventListener("command", (e) => { + this._toggleRegexHelp(e.target.value); + }); + + // Input validation + document.getElementById("zenATCNewRuleMatchValue").addEventListener("input", () => { + this._validateNewRuleInput(); + }); + + + }, + + _toggleRegexHelp(matchType) { + const regexHelp = document.getElementById("zenATCRegexHelp"); + regexHelp.hidden = matchType !== "regex"; + }, + + // Helper: returns an error message string if the match parameters are invalid, otherwise null + _getMatchValidationError(matchType, matchValue) { + if (!matchValue) { + return "Please enter a URL pattern"; + } + + if (matchType === "regex" && matchValue) { + try { + new RegExp(matchValue); + } catch (e) { + return "Invalid regular expression: " + e.message; + } + } + + return null; + }, + + _validateNewRuleInput() { + const matchType = document.getElementById("zenATCNewRuleMatchType").value; + const matchValue = document.getElementById("zenATCNewRuleMatchValue").value.trim(); + const errorElement = document.getElementById("zenATCNewRuleError"); + + const errorMsg = this._getMatchValidationError(matchType, matchValue); + + if (errorMsg) { + errorElement.textContent = errorMsg; + errorElement.hidden = false; + return false; + } + + errorElement.hidden = true; + return true; + }, + + _addRule() { + const matchType = document.getElementById("zenATCNewRuleMatchType").value; + const matchValue = document.getElementById("zenATCNewRuleMatchValue").value.trim(); + const workspaceId = document.getElementById("zenATCNewRuleWorkspace").value; + + // Validate inputs + const validationError = this._getMatchValidationError(matchType, matchValue); + if (validationError) { + this._showError("zenATCNewRuleError", validationError); + return; + } + + if (!workspaceId) { + this._showError("zenATCNewRuleError", "Please select a workspace"); + return; + } + + try { + ZenAirTrafficControl.createRule({ + matchType, + matchValue, + workspaceId + }); + + // Clear form + document.getElementById("zenATCNewRuleMatchValue").value = ""; + document.getElementById("zenATCNewRuleError").hidden = true; + + this._refreshRulesList(); + } catch (e) { + console.error("Error creating rule:", e); + this._showError("zenATCNewRuleError", "Failed to create rule: " + e.message); + } + }, + + _showError(elementId, message) { + const errorElement = document.getElementById(elementId); + errorElement.textContent = message; + errorElement.hidden = false; + }, + + async _exportRules() { + const rules = ZenAirTrafficControl.getRules(); + if (rules.length === 0) { + alert("No rules to export."); + return; + } + + // Transform rules to use workspace names rather than IDs + const transformed = rules.map(r => { + const ws = this._workspaces.find(w => w.uuid === r.workspaceId); + return { + matchType: r.matchType, + matchValue: r.matchValue, + workspaceName: ws ? ws.name : "(unknown)", + enabled: r.enabled !== false + }; + }); + + const exportData = { + version: "1.1", + exportDate: new Date().toISOString(), + rules: transformed + }; + + const jsonString = JSON.stringify(exportData, null, 2); + + // Use Downloads API to save the file + const { Downloads } = ChromeUtils.importESModule("resource://gre/modules/Downloads.sys.mjs"); + // Include timestamp to avoid overwrites + const now = new Date(); + const dateStr = now.toISOString().split("T")[0]; + const timeStr = now.toTimeString().split(" ")[0].replace(/:/g, "-"); + const filename = `zen-air-traffic-control-rules-${dateStr}-${timeStr}.json`; + + try { + // Create a data URI from the JSON + const dataURI = "data:application/json;charset=utf-8," + encodeURIComponent(jsonString); + + // Get proper downloads directory + const downloadsDir = await Downloads.getSystemDownloadsDirectory(); + + // Create download + const download = await Downloads.createDownload({ + source: dataURI, + target: { + path: PathUtils.join(downloadsDir, filename) + } + }); + + // Add to downloads list and start + const list = await Downloads.getList(Downloads.PUBLIC); + await list.add(download); + await download.start(); + + alert(`Exported ${rules.length} rules to Downloads folder`); + } catch (e) { + console.error("Failed to export rules:", e); + alert("Failed to export rules: " + e.message); + } + }, + + _importRules() { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + + input.onchange = (event) => { + const file = event.target.files[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (e) => { + try { + const importData = JSON.parse(e.target.result); + + if (!importData.rules || !Array.isArray(importData.rules)) { + alert("Invalid file: missing 'rules' array."); + return; + } + + const importedNames = [...new Set(importData.rules.map(r => r.workspaceName))]; + + // Show mapping dialog + this._showImportMappingDialog(importedNames, importData.rules); + } catch(err) { + console.error("Import error", err); + alert("Failed to import: " + err.message); + } + }; + reader.readAsText(file); + }; + + input.click(); + }, + + _showImportMappingDialog(importedWorkspaceNames, rules) { + // Create overlay + const overlay = document.createElement("div"); + overlay.className = "zen-atc-import-overlay"; + overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + `; + + // Close on click-outside + overlay.addEventListener("click", (e) => { + if (e.target === overlay) { + document.body.removeChild(overlay); + } + }); + + // Close on Escape key + const escListener = (e) => { + if (e.key === "Escape") { + document.body.removeChild(overlay); + document.removeEventListener("keydown", escListener); + } + }; + document.addEventListener("keydown", escListener); + + // Create dialog from template + const template = document.getElementById("zenATCImportMappingTemplate"); + const dialog = template.content.cloneNode(true); + const dialogElement = dialog.querySelector(".zen-atc-import-dialog"); + dialogElement.style.cssText = ` + background: var(--in-content-page-background); + border: 1px solid var(--in-content-border-color); + border-radius: 8px; + padding: 20px; + min-width: 400px; + max-width: 600px; + `; + + const mappingsContainer = dialog.querySelector(".zen-atc-import-mappings"); + const mappings = {}; + + // Create mapping rows + for (const importedName of importedWorkspaceNames) { + const rowTemplate = document.getElementById("zenATCImportMappingRowTemplate"); + const row = rowTemplate.content.cloneNode(true); + + row.querySelector(".zen-atc-import-source-name").textContent = importedName; + + const dropdown = row.querySelector(".zen-atc-import-target-dropdown"); + this._populateWorkspaceDropdown(dropdown); + + // Auto-select if exact match exists + const exactMatch = this._workspaces.find(ws => ws.name === importedName); + if (exactMatch) { + dropdown.value = exactMatch.uuid; + dropdown.setAttribute("value", exactMatch.uuid); + } else if (this._workspaces.length > 0) { + dropdown.value = this._workspaces[0].uuid; + dropdown.setAttribute("value", this._workspaces[0].uuid); + } + + mappings[importedName] = dropdown; + mappingsContainer.appendChild(row); + } + + // Handle buttons + dialog.querySelector(".zen-atc-import-cancel").addEventListener("click", () => { + document.body.removeChild(overlay); + }); + + dialog.querySelector(".zen-atc-import-confirm").addEventListener("click", () => { + // Build final mapping + const nameMapping = {}; + for (const [name, dropdown] of Object.entries(mappings)) { + // Get the selected value from the dropdown (UUID) + const selectedValue = (dropdown.selectedItem && dropdown.selectedItem.value) || dropdown.value; + nameMapping[name] = selectedValue; + } + + // Clear old rules + for (const existing of ZenAirTrafficControl.getRules()) { + ZenAirTrafficControl.deleteRule(existing.uuid); + } + + // Import with mapped IDs + let importedCount = 0; + for (const rule of rules) { + const workspaceId = nameMapping[rule.workspaceName]; + if (!workspaceId) continue; + + ZenAirTrafficControl.createRule({ + matchType: rule.matchType, + matchValue: rule.matchValue, + workspaceId, + enabled: rule.enabled !== false + }); + importedCount++; + } + + document.body.removeChild(overlay); + alert(`Successfully imported ${importedCount} rules.`); + this._refreshRulesList(); + }); + + overlay.appendChild(dialogElement); + document.body.appendChild(overlay); + }, + + _refreshRulesList() { + const rules = ZenAirTrafficControl.getRules(); + const container = document.getElementById("zenATCRulesList"); + + // Clear existing rules + while (container.firstChild) { + container.removeChild(container.firstChild); + } + + if (rules.length === 0) { + const emptyMessage = document.createElement("div"); + emptyMessage.className = "zen-atc-empty-message"; + emptyMessage.textContent = "No routing rules configured yet."; + container.appendChild(emptyMessage); + return; + } + + // Create rule items + for (const rule of rules) { + const ruleElement = this._createRuleElement(rule); + container.appendChild(ruleElement); + } + }, + + _createRuleElement(rule) { + const template = document.getElementById("zenATCRuleTemplate"); + const fragment = template.content.cloneNode(true); + + // Set rule data + const ruleItem = fragment.querySelector(".zen-atc-rule-item"); + ruleItem.setAttribute("data-rule-id", rule.uuid); + + // Add disabled class if rule is disabled + if (rule.enabled === false) { + ruleItem.classList.add("zen-atc-rule-disabled"); + } + + // Toggle + const toggle = fragment.querySelector(".zen-atc-rule-toggle"); + // Clear any default state from the template + toggle.removeAttribute("checked"); + toggle.checked = false; + // Now set the actual state + if (rule.enabled !== false) { + toggle.setAttribute("checked", "true"); + toggle.checked = true; + } + toggle.addEventListener("command", (e) => { + // XUL checkboxes fire 'command' event after the checked state changes + try { + // Get the new state from the checkbox (it has already changed) + const newState = e.target.checked; + + // Update the rule + ZenAirTrafficControl.updateRule(rule.uuid, { enabled: newState }); + + // Update the visual state to match + const ruleElement = e.target.closest(".zen-atc-rule-item"); + if (newState) { + e.target.setAttribute("checked", "true"); + ruleElement.classList.remove("zen-atc-rule-disabled"); + } else { + e.target.removeAttribute("checked"); + ruleElement.classList.add("zen-atc-rule-disabled"); + } + + // Update the rule details to show disabled state + const detailsElement = ruleElement.querySelector(".zen-atc-rule-details"); + if (detailsElement) { + const updatedRule = { ...rule, enabled: newState }; + detailsElement.textContent = this._formatRuleDetails(updatedRule); + } + + // Sync in-memory rule object + rule.enabled = newState; + } catch (error) { + // Revert on error + e.target.checked = !e.target.checked; + } + }); + + // Rule summary and details + const summary = fragment.querySelector(".zen-atc-rule-summary"); + const details = fragment.querySelector(".zen-atc-rule-details"); + + summary.textContent = this._formatRuleSummary(rule); + details.textContent = this._formatRuleDetails(rule); + + // Edit button + const editButton = fragment.querySelector(".zen-atc-rule-edit"); + editButton.addEventListener("click", () => this._editRule(rule)); + + // Delete button + const deleteButton = fragment.querySelector(".zen-atc-rule-delete"); + deleteButton.addEventListener("click", () => this._deleteRule(rule.uuid)); + + return fragment; + }, + + _formatRuleSummary(rule) { + const workspace = this._workspaces.find(ws => ws.uuid === rule.workspaceId); + const workspaceName = workspace ? workspace.name : "Unknown workspace"; + return `${rule.matchValue} → ${workspaceName}`; + }, + + _formatRuleDetails(rule) { + const matchTypeLabels = { + equals: "equals", + contains: "contains", + startsWith: "starts with", + endsWith: "ends with", + regex: "matches regex" + }; + + const matchLabel = matchTypeLabels[rule.matchType] || rule.matchType; + const details = `URL ${matchLabel} "${rule.matchValue}"`; + + // Add disabled indicator if rule is disabled + if (rule.enabled === false) { + return `${details} • Disabled`; + } + + return details; + }, + + _editRule(rule) { + // Cancel any existing edit + if (this._currentEditingRule) { + this._cancelEdit(); + } + + this._currentEditingRule = rule; + + // Find the rule element + const ruleElement = document.querySelector(`[data-rule-id="${rule.uuid}"]`); + if (!ruleElement) return; + + // Replace with edit form + const editTemplate = document.getElementById("zenATCEditRuleTemplate"); + const editFragment = editTemplate.content.cloneNode(true); + + // Populate edit form + const editItem = editFragment.querySelector(".zen-atc-rule-item"); + editItem.setAttribute("data-rule-id", rule.uuid); + + // Note: Toggle removed from edit template for better layout + + // Match type + const matchTypeDropdown = editFragment.querySelector(".zen-atc-edit-match-type"); + this._populateMatchTypeDropdown(matchTypeDropdown); + matchTypeDropdown.value = rule.matchType; + // Force the dropdown to update its display + matchTypeDropdown.setAttribute("value", rule.matchType); + + // Match value + const matchValueInput = editFragment.querySelector(".zen-atc-edit-match-value"); + matchValueInput.value = rule.matchValue; + + // Workspace + const workspaceDropdown = editFragment.querySelector(".zen-atc-edit-workspace"); + this._populateWorkspaceDropdown(workspaceDropdown, rule.workspaceId); + + // Save button + const saveButton = editFragment.querySelector(".zen-atc-rule-save"); + saveButton.addEventListener("click", () => this._saveEdit()); + + // Cancel button + const cancelButton = editFragment.querySelector(".zen-atc-rule-cancel"); + cancelButton.addEventListener("click", () => this._cancelEdit()); + + // Replace the rule element + ruleElement.parentNode.replaceChild(editFragment, ruleElement); + }, + + _populateMatchTypeDropdown(dropdown) { + const menupopup = dropdown.querySelector("menupopup"); + // Menuitems are already in the template, just need to make sure they're there + if (menupopup.children.length === 0) { + const matchTypes = [ + { value: "equals", label: "equals" }, + { value: "contains", label: "contains" }, + { value: "startsWith", label: "starts with" }, + { value: "endsWith", label: "ends with" }, + { value: "regex", label: "regex" } + ]; + + for (const type of matchTypes) { + const option = document.createXULElement("menuitem"); + option.setAttribute("value", type.value); + option.setAttribute("label", type.label); + menupopup.appendChild(option); + } + } + }, + + _saveEdit() { + if (!this._currentEditingRule) return; + + const editElement = document.querySelector(`[data-rule-id="${this._currentEditingRule.uuid}"].zen-atc-rule-editing`); + if (!editElement) return; + + // Get form values + const matchTypeDropdown = editElement.querySelector(".zen-atc-edit-match-type"); + const matchValueInput = editElement.querySelector(".zen-atc-edit-match-value"); + const workspaceDropdown = editElement.querySelector(".zen-atc-edit-workspace"); + + const matchType = (matchTypeDropdown.selectedItem && matchTypeDropdown.selectedItem.value) || matchTypeDropdown.value; + const matchValue = matchValueInput.value.trim(); + const workspaceId = (workspaceDropdown.selectedItem && workspaceDropdown.selectedItem.value) || workspaceDropdown.value; + + // Validate using shared helper + const validationError = this._getMatchValidationError(matchType, matchValue); + if (validationError) { + this._showEditError(editElement, validationError); + return; + } + + if (!workspaceId) { + this._showEditError(editElement, "Please select a workspace"); + return; + } + + try { + ZenAirTrafficControl.updateRule(this._currentEditingRule.uuid, { + matchType, + matchValue, + workspaceId + }); + + this._currentEditingRule = null; + this._refreshRulesList(); + } catch (e) { + console.error("Error updating rule:", e); + this._showEditError(editElement, "Failed to update rule: " + e.message); + } + }, + + _showEditError(editElement, message) { + const errorElement = editElement.querySelector(".zen-atc-edit-error"); + errorElement.textContent = message; + errorElement.hidden = false; + }, + + _cancelEdit() { + this._currentEditingRule = null; + this._refreshRulesList(); + }, + + _deleteRule(uuid) { + if (confirm("Are you sure you want to delete this routing rule?")) { + ZenAirTrafficControl.deleteRule(uuid); + this._refreshRulesList(); + } + } +}; diff --git a/src/browser/components/preferences/zen-preferences-links.xhtml b/src/browser/components/preferences/zen-preferences-links.xhtml index ebc2252af5..e808ee4c6b 100644 --- a/src/browser/components/preferences/zen-preferences-links.xhtml +++ b/src/browser/components/preferences/zen-preferences-links.xhtml @@ -3,5 +3,9 @@ rel="stylesheet" href="chrome://browser/skin/preferences/zen-preferences.css" /> +