diff --git a/src/layouts/partial-panel-resolver.js b/src/layouts/partial-panel-resolver.js
index 0a7c9afe4d..cd50f90e08 100644
--- a/src/layouts/partial-panel-resolver.js
+++ b/src/layouts/partial-panel-resolver.js
@@ -49,6 +49,10 @@ function ensureLoaded(panel) {
imported = import(/* webpackChunkName: "panel-dev-template" */ '../panels/dev-template/ha-panel-dev-template.js');
break;
+ case 'experimental-ui':
+ imported = import(/* webpackChunkName: "panel-experimental-ui" */ '../panels/experimental-ui/ha-panel-experimental-ui.js');
+ break;
+
case 'history':
imported = import(/* webpackChunkName: "panel-history" */ '../panels/history/ha-panel-history.js');
break;
diff --git a/src/panels/experimental-ui/ha-panel-experimental-ui.js b/src/panels/experimental-ui/ha-panel-experimental-ui.js
new file mode 100644
index 0000000000..e48e57041b
--- /dev/null
+++ b/src/panels/experimental-ui/ha-panel-experimental-ui.js
@@ -0,0 +1,96 @@
+import '@polymer/app-layout/app-header-layout/app-header-layout.js';
+import '@polymer/app-layout/app-header/app-header.js';
+import '@polymer/app-layout/app-toolbar/app-toolbar.js';
+import '@polymer/paper-icon-button/paper-icon-button.js';
+
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import './hui-view.js';
+
+class ExperimentalUI extends PolymerElement {
+ static get template() {
+ return html`
+
+
+
+
+
+ Experimental UI
+
+
+
+
+
+
+ `;
+ }
+
+ static get properties() {
+ return {
+ hass: Object,
+
+ narrow: {
+ type: Boolean,
+ value: false,
+ },
+
+ showMenu: {
+ type: Boolean,
+ value: false,
+ },
+
+ _columns: {
+ type: Number,
+ value: 1,
+ },
+
+ _config: {
+ type: Object,
+ value: null,
+ observer: '_configChanged',
+ },
+
+ _curView: Object
+ };
+ }
+
+ ready() {
+ super.ready();
+ this._fetchConfig();
+ this._handleWindowChange = this._handleWindowChange.bind(this);
+ this.mqls = [300, 600, 900, 1200].map((width) => {
+ const mql = matchMedia(`(min-width: ${width}px)`);
+ mql.addListener(this._handleWindowChange);
+ return mql;
+ });
+ this._handleWindowChange();
+ }
+
+ _handleWindowChange() {
+ const matchColumns = this.mqls.reduce((cols, mql) => cols + mql.matches, 0);
+ // Do -1 column if the menu is docked and open
+ this._columns = Math.max(1, matchColumns - (!this.narrow && this.showMenu));
+ }
+
+ _fetchConfig() {
+ this.hass.connection.sendMessagePromise({ type: 'frontend/experimental_ui' })
+ .then((conf) => { this._config = conf.result; });
+ }
+
+ _configChanged(config) {
+ if (!config) return;
+ // Currently hardcode to first view.
+ this._curView = config.views[0];
+ }
+}
+
+customElements.define('ha-panel-experimental-ui', ExperimentalUI);
diff --git a/src/panels/experimental-ui/hui-entities-card.js b/src/panels/experimental-ui/hui-entities-card.js
new file mode 100644
index 0000000000..2c126beed4
--- /dev/null
+++ b/src/panels/experimental-ui/hui-entities-card.js
@@ -0,0 +1,114 @@
+import '@polymer/iron-flex-layout/iron-flex-layout-classes.js';
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import stateCardType from '../../common/entity/state_card_type.js';
+
+import '../../components/ha-card.js';
+
+// just importing this now as shortcut to import correct state-card-*
+import '../../state-summary/state-card-content.js';
+
+// Support for overriding type from attributes should be removed
+// Instead, it should be coded inside entity config.
+// stateCardType requires `hass` because we check if a service exists.
+// This should also be determined during runtime.
+function stateElement(hass, entityId, stateObj) {
+ if (!stateObj) {
+ return 'state-card-display';
+ } else if (stateObj.attributes && 'custom_ui_state_card' in stateObj.attributes) {
+ return stateObj.attributes.custom_ui_state_card;
+ }
+ return 'state-card-' + stateCardType(hass, stateObj);
+}
+
+class HuiEntitiesCard extends PolymerElement {
+ static get template() {
+ return html`
+
+
+
+
+
+
+`;
+ }
+
+ static get properties() {
+ return {
+ hass: {
+ type: Object,
+ observer: '_hassChanged',
+ },
+ config: {
+ type: Object,
+ observer: '_configChanged',
+ }
+ };
+ }
+
+ constructor() {
+ super();
+ this._elements = [];
+ }
+
+ getCardSize() {
+ // +1 for the header
+ return 1 + (this.config ? this.config.entities.length : 0);
+ }
+
+ _computeTitle(config) {
+ return config.title;
+ }
+
+ _configChanged(config) {
+ const root = this.$.states;
+
+ while (root.lastChild) {
+ root.removeChild(root.lastChild);
+ }
+
+ this._elements = [];
+
+ for (let i = 0; i < config.entities.length; i++) {
+ const entityId = config.entities[i];
+ const stateObj = this.hass.states[entityId];
+ const tag = stateElement(this.hass, entityId, stateObj);
+ const element = document.createElement(tag);
+ element.stateObj = stateObj;
+ element.hass = this.hass;
+ this._elements.push({ entityId, element });
+ root.appendChild(element);
+ }
+ }
+
+ _hassChanged(hass) {
+ for (let i = 0; i < this._elements.length; i++) {
+ const { entityId, element } = this._elements[i];
+ const stateObj = hass.states[entityId];
+ element.stateObj = stateObj;
+ element.hass = hass;
+ }
+ }
+}
+customElements.define('hui-entities-card', HuiEntitiesCard);
diff --git a/src/panels/experimental-ui/hui-entity-filter-card.js b/src/panels/experimental-ui/hui-entity-filter-card.js
new file mode 100644
index 0000000000..2ee2b87087
--- /dev/null
+++ b/src/panels/experimental-ui/hui-entity-filter-card.js
@@ -0,0 +1,55 @@
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import './hui-entities-card.js';
+
+import computeStateDomain from '../../common/entity/compute_state_domain.js';
+
+class HuiEntitiesCard extends PolymerElement {
+ static get template() {
+ return html`
+
+`;
+ }
+
+ static get properties() {
+ return {
+ hass: Object,
+ config: Object,
+ };
+ }
+
+ getCardSize() {
+ // +1 for the header
+ return 1 + this._getEntities(this.hass, this.config.filter).length;
+ }
+
+ // Return a list of entities based on a filter.
+ _getEntities(hass, filter) {
+ const filters = [];
+
+ if (filter.domain) {
+ const domain = filter.domain;
+ filters.push(stateObj => computeStateDomain(stateObj) === domain);
+ }
+
+ if (filter.state) {
+ const state = filter.state;
+ filters.push(stateObj => stateObj.state === state);
+ }
+
+ return Object.values(hass.states)
+ .filter(stateObj => filters.every(filterFunc => filterFunc(stateObj)))
+ .map(stateObj => stateObj.entity_id);
+ }
+
+ _computeCardConfig(hass, config) {
+ return Object.assign({}, config.card_config || {}, {
+ entities: this._getEntities(hass, config.filter),
+ });
+ }
+}
+customElements.define('hui-entity-filter-card', HuiEntitiesCard);
diff --git a/src/panels/experimental-ui/hui-view.js b/src/panels/experimental-ui/hui-view.js
new file mode 100644
index 0000000000..c10f9970cf
--- /dev/null
+++ b/src/panels/experimental-ui/hui-view.js
@@ -0,0 +1,182 @@
+import { html } from '@polymer/polymer/lib/utils/html-tag.js';
+import { PolymerElement } from '@polymer/polymer/polymer-element.js';
+
+import './hui-entities-card.js';
+import './hui-entity-filter-card.js';
+
+import applyThemesOnElement from '../../common/dom/apply_themes_on_element.js';
+
+const VALID_TYPES = ['entities', 'entity-filter'];
+const CUSTOM_TYPE_PREFIX = 'custom:';
+
+function cardElement(type) {
+ if (VALID_TYPES.includes(type)) {
+ return `hui-${type}-card`;
+ } else if (type.startsWith(CUSTOM_TYPE_PREFIX)) {
+ return type.substr(CUSTOM_TYPE_PREFIX.length);
+ }
+ return null;
+}
+
+class HaView extends PolymerElement {
+ static get template() {
+ return html`
+
+
+ `;
+ }
+ static get properties() {
+ return {
+ hass: {
+ type: Object,
+ observer: '_hassChanged',
+ },
+
+ columns: {
+ type: Number,
+ observer: '_configChanged',
+ },
+
+ config: {
+ type: Object,
+ observer: '_configChanged',
+ },
+ };
+ }
+
+ constructor() {
+ super();
+ this._elements = [];
+ }
+
+ _getElements(cards) {
+ const elements = [];
+
+ for (let i = 0; i < cards.length; i++) {
+ const cardConfig = cards[i];
+ const tag = cardElement(cardConfig.type);
+ if (!tag) {
+ // eslint-disable-next-line
+ console.error('Unknown type encountered:', cardConfig.type);
+ continue;
+ }
+ const element = document.createElement(tag);
+ element.config = cardConfig;
+ element.hass = this.hass;
+ elements.push(element);
+ }
+
+ return elements;
+ }
+
+ _configChanged() {
+ const root = this.$.columns;
+ const config = this.config;
+
+ while (root.lastChild) {
+ root.removeChild(root.lastChild);
+ }
+
+ if (!config) {
+ this._elements = [];
+ return;
+ }
+
+ const elements = this._getElements(config.cards);
+
+ let columns = [];
+ const columnEntityCount = [];
+ for (let i = 0; i < this.columns; i++) {
+ columns.push([]);
+ columnEntityCount.push(0);
+ }
+
+ // Find column with < 5 entities, else column with lowest count
+ function getColumnIndex(size) {
+ let minIndex = 0;
+ for (let i = 0; i < columnEntityCount.length; i++) {
+ if (columnEntityCount[i] < 5) {
+ minIndex = i;
+ break;
+ }
+ if (columnEntityCount[i] < columnEntityCount[minIndex]) {
+ minIndex = i;
+ }
+ }
+
+ columnEntityCount[minIndex] += size;
+
+ return minIndex;
+ }
+
+ elements.forEach(el =>
+ columns[getColumnIndex(el.getCardSize())].push(el));
+
+ // Remove empty columns
+ columns = columns.filter(val => val.length > 0);
+
+ columns.forEach((column) => {
+ const columnEl = document.createElement('div');
+ columnEl.classList.add('column');
+ column.forEach(el => columnEl.appendChild(el));
+ root.appendChild(columnEl);
+ });
+
+ this._elements = elements;
+
+ if ('theme' in config) {
+ applyThemesOnElement(root, this.hass.themes, config.theme);
+ }
+ }
+
+ _hassChanged(hass) {
+ for (let i = 0; i < this._elements.length; i++) {
+ this._elements[i].hass = hass;
+ }
+ }
+}
+
+customElements.define('hui-view', HaView);