const CARD_TAG = "k-cfs-card";
const EDITOR_TAG = "k-cfs-card-editor";
const mdi = (name) => `mdi:${name}`;
const ASSET_URL_BASE = "/ha_creality_ws/";
const I18N_URL_BASE = `${ASSET_URL_BASE}i18n/`;
const _i18nData = {};
const _i18nPromises = {};
function _loadI18n(lang) {
if (_i18nData[lang]) return Promise.resolve(_i18nData[lang]);
if (_i18nPromises[lang]) return _i18nPromises[lang];
_i18nPromises[lang] = fetch(`${I18N_URL_BASE}${lang}.json`)
.then((res) => (res.ok ? res.json() : null))
.then((data) => {
if (data) _i18nData[lang] = data;
else _i18nPromises[lang] = null;
return data;
})
.catch(() => { _i18nPromises[lang] = null; return null; });
return _i18nPromises[lang];
}
function _resolveLang(hass) {
return hass?.locale?.language || hass?.language || "en";
}
function _translate(hass, section, fallbackDict, key, vars) {
const lang = _resolveLang(hass);
const short = lang.split("-")[0];
const remote = _i18nData[lang]?.[section] ?? _i18nData[short]?.[section];
const remoteEn = _i18nData["en"]?.[section];
let text = (remote && key in remote) ? remote[key]
: (remoteEn && key in remoteEn) ? remoteEn[key]
: (fallbackDict[lang]?.[key] ?? fallbackDict[short]?.[key] ?? fallbackDict["en"]?.[key] ?? key);
if (vars) {
Object.entries(vars).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{${k}\\}`, "g"), v);
});
}
return text;
}
function _requestI18n(instance, hass, onLoaded) {
if (instance._i18nRequested) return;
instance._i18nRequested = true;
const lang = _resolveLang(hass).split("-")[0];
Promise.all([_loadI18n("en"), lang !== "en" ? _loadI18n(lang) : null]).then(onLoaded);
}
// Creality's standard filament colours. Deliberately untranslated: the names are
// part of the product's identity, and twelve colour words per language is poor
// value. They surface as tooltips only.
const CREALITY_STANDARD_COLOURS = {
Black: "#000000",
White: "#ffffff",
Grey: "#9e9e9e",
Red: "#e53935",
Orange: "#fb8c00",
Yellow: "#fdd835",
Green: "#06c84f",
Cyan: "#00acc1",
Blue: "#1e88e5",
Purple: "#8e24aa",
Pink: "#ec407a",
Lime: "#9ccc65",
};
const PRESETS_STORAGE_KEY = "k-cfs-colour-presets";
/**
* Escape a value for interpolation into markup.
*
* Filament names, colours and entity ids reach the renderers straight from
* printer telemetry, and the renderers build strings that are assigned to
* innerHTML. A `"` or `<` in any of them would otherwise close the attribute or
* element it sits in and inject markup into the dashboard.
* @param {*} value
* @returns {string}
*/
function esc(value) {
return String(value ?? "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
/**
* Whether a value is a hex colour: 3 or 6 digits, `#` optional.
* @param {*} value
* @returns {boolean}
*/
function isHexColour(value) {
return /^#?(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(value ?? "").trim());
}
/**
* Expand a hex colour to the six-digit form, or "" if it is not one.
*
* The service only accepts six digits, so a preset stored as `#abc` would be
* selectable and then rejected on save.
* @param {*} value
* @returns {string}
*/
function toSixDigitHex(value) {
if (!isHexColour(value)) return "";
const hex = String(value).trim().replace(/^#/, "").toLowerCase();
return hex.length === 3 ? `#${hex.replace(/./g, (c) => c + c)}` : `#${hex}`;
}
/**
* User-defined colour presets, persisted in localStorage.
*
* Deliberately browser-local rather than stored in the card config: presets are
* a personal palette, and writing them into the dashboard would make every
* change a config edit visible to every user of that dashboard.
*/
class ColourPresetsManager {
constructor() {
this.presets = ColourPresetsManager._load();
}
static _load() {
try {
const stored = localStorage.getItem(PRESETS_STORAGE_KEY);
const parsed = stored ? JSON.parse(stored) : {};
// Guard against a hand-edited or corrupted value.
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
_persist() {
try {
localStorage.setItem(PRESETS_STORAGE_KEY, JSON.stringify(this.presets));
} catch (_) {
// Private browsing or a full quota. The presets stay in memory for this
// session; losing them is not worth interrupting a save for.
}
}
/** @returns {boolean} whether the preset was stored */
save(name, colour) {
const key = String(name || "").trim();
// Validate the input, not the output: _sanitizeColor returns #cccccc both
// for "unparseable" and for the real grey #cccccc, so comparing against it
// would refuse to store that colour.
const value = toSixDigitHex(colour);
if (!key || !value) return false;
this.presets[key] = value;
this._persist();
return true;
}
rename(from, to) {
const target = String(to || "").trim();
if (!target || !(from in this.presets) || target === from) return false;
this.presets[target] = this.presets[from];
delete this.presets[from];
this._persist();
return true;
}
remove(name) {
if (!(name in this.presets)) return false;
delete this.presets[name];
this._persist();
return true;
}
/** Standard colours first, then the user's own. @returns {Array<[string, string]>} */
entries() {
return [
...Object.entries(CREALITY_STANDARD_COLOURS),
...Object.entries(this.presets),
];
}
isCustom(name) {
return name in this.presets;
}
}
/**
* The one presets store for this page.
*
* Per-card instances each cached localStorage in their own constructor, so
* saving or deleting in one card's dialog left every other card on the dashboard
* serving a stale copy until reload. Created lazily: rendering a card must not
* touch storage, only opening a dialog does.
* @returns {ColourPresetsManager}
*/
let _sharedPresets = null;
function sharedPresets() {
if (!_sharedPresets) _sharedPresets = new ColourPresetsManager();
return _sharedPresets;
}
// The values that mean "no reading", in one place. Home Assistant renders an
// entity with no value as "unknown" and an unavailable one as "unavailable", and
// the integration writes "-" for an empty CFS slot. Trimmed before comparing, so
// a padded " unknown " is caught everywhere rather than only in the edit form --
// each caller keeps its own fallback, since the display wants "-" and the form
// wants "".
const SENTINEL_VALUES = new Set(["unknown", "unavailable", "-"]);
function isSentinel(value) {
if (value === undefined || value === null) return true;
const text = String(value).trim();
return text === "" || SENTINEL_VALUES.has(text.toLowerCase());
}
const BUSY_PRINT_STATES = new Set([
"printing",
"paused",
"processing",
"self-testing",
]);
const CFS_TRANSLATIONS = {
en: {
no_data: "No CFS data available",
ext_label: "EXT",
cfs_label: "CFS",
cfs_number_label: "CFS {number}",
// Editor
label_card_title: "Card Title",
label_external_filament: "External Filament",
label_external_color: "External Color",
label_external_percent: "External Percent",
label_box_temp: "Box {box} Temperature",
label_box_humidity: "Box {box} Humidity",
label_slot_filament: "Box {box} Slot {slot} Filament",
label_slot_color: "Box {box} Slot {slot} Color",
label_slot_percent: "Box {box} Slot {slot} Remaining Percent",
label_color_presets: "Presets",
label_preset_name: "Preset name",
btn_save_preset: "Save preset",
toast_preset_saved: "Preset “{name}” saved",
toast_preset_deleted: "Preset “{name}” deleted",
toast_preset_name_required: "Give the preset a name first",
hint_delete_preset: "Dashed swatches are your own presets; right-click one to delete it.",
btn_edit: "Edit material",
tooltip_edit_locked: "Editing is disabled while the printer is busy",
tooltip_multicolour_readonly: "Multi-color spools cannot be edited",
dialog_edit_title: "Edit material",
dialog_edit_target: "Writing to box {box}, slot {slot}",
label_material_type: "Material type",
label_material_name: "Material name",
label_material_vendor: "Vendor",
label_material_color: "Color",
label_material_min_temp: "Minimum temperature",
label_material_max_temp: "Maximum temperature",
label_material_pressure: "Pressure advance",
btn_cancel: "Cancel",
btn_save: "Save",
btn_saving: "Saving…",
toast_saved: "Material saved",
toast_save_failed: "Could not save material: {error}",
toast_temp_range_invalid: "Maximum temperature must not be below the minimum",
toast_colour_invalid: "Color must be six hex digits, for example #06c84f",
toast_type_required: "Material type is required",
toast_external_not_supported: "This printer does not report a box id for the external spool, so it cannot be edited",
warn_box_id_guessed: "The target box or slot was inferred from the card layout; check both match the printer before saving",
toast_no_device: "Could not identify the printer for this card. Check the entities in the card configuration.",
toast_multiple_devices: "This card mixes entities from more than one printer, so material editing is disabled.",
toast_printer_busy: "Cannot edit material while the printer is busy",
schema_view_mode: "Display Mode",
view_mode_box: "Box (visual)",
alt_cfs_box: "Creality CFS unit",
view_mode_full: "Full",
view_mode_compact: "Compact",
schema_show_type_in_mini: "Show Filament Type in Mini Mode",
// Editor tab/header
tab_entities: "Entities",
tab_theme: "Theme",
},
};
class KCFSCard extends HTMLElement {
constructor() {
super();
this._selectedCFS = 0; // Track selected CFS tab in normal mode
}
/** The colour the printer reported, or null when it reported none usable.
*
* Split out from `_sanitizeColor` so that "no colour" and "grey" stay
* distinguishable. The display grey is a placeholder, and the edit dialog
* used to prefill it like a real value -- so changing only the material type
* and saving wrote `#cccccc` to the spool as though the printer had said so.
*/
static _parseColor(value) {
if (isSentinel(value)) return null;
const raw = String(value).trim();
const hex = raw.startsWith("#") ? raw.slice(1) : raw;
if (hex.length === 6 && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
if (hex.length === 3 && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.toLowerCase()}`;
}
if (hex.length === 7 && hex.startsWith("0") && /^[0-9a-fA-F]+$/.test(hex)) {
return `#${hex.slice(1).toLowerCase()}`;
}
return null;
}
static _sanitizeColor(value) {
return KCFSCard._parseColor(value) ?? "#cccccc";
}
/**
* Bring a stored config up to date.
*
* compact_view was a boolean; view_mode is "full" | "compact" | "box". The
* legacy key is translated once and then dropped, so the first visual edit
* writes clean YAML back.
* @param {object} config
* @returns {object}
*/
static _migrateConfig(config) {
const cfg = { ...(config || {}) };
if (cfg.compact_view !== undefined && cfg.view_mode === undefined) {
cfg.view_mode = cfg.compact_view ? "compact" : "full";
}
delete cfg.compact_view;
return cfg;
}
/** Map a view mode to its ha-card class. "full" is historically "normal-mode". */
static _modeClass(viewMode) {
if (viewMode === "compact") return "compact-mode";
if (viewMode === "box") return "box-mode";
return "normal-mode";
}
/**
* True when the printer reports several colours for one spool, e.g.
* "#0ffa800,#0ff97e1". _sanitizeColor renders those as grey, and a single
* colour cannot represent them, so editing has to leave the value alone
* rather than flatten it.
* @param {*} value raw colour as reported
* @returns {boolean}
*/
static _isMultiColour(value) {
return /[,;]/.test(String(value || ""));
}
/**
* Work out which box and slot the *printer* calls this one.
*
* The card's box0..box3 config keys are card positions, not printer ids -- the
* sensors key off the id in materialBoxs[]. Three tiers, most reliable first:
* 1. the box_id/slot_id attributes the sensors publish;
* 2. the default entity id, which encodes them as cfs_box_N_slot_M_*;
* 3. card position + 1, which is only right when the pickers were filled in
* order from box 1 -- flagged as a guess so the UI can say so.
* @returns {{boxId: number, slotId: number, guessed: boolean}}
*/
static _resolvePrinterTarget(filamentObj, entityId, cardBoxIndex, cardSlotIndex) {
const attrs = filamentObj?.attributes;
if (typeof attrs?.box_id === "number" && typeof attrs?.slot_id === "number") {
return { boxId: attrs.box_id, slotId: attrs.slot_id, guessed: false };
}
const match = /cfs_box_(\d+)_slot_(\d+)_/.exec(String(entityId || ""));
// Home Assistant builds the entity id from the entity *name*, and the name
// counts slots from 1 for the reader ("CFS Box 1 Slot 2") while the printer
// counts them from 0. Passing the captured number straight through
// addressed the slot after the one on screen, so a save overwrote the
// neighbouring spool. Only the unique id is 0-based, and that never reaches
// the frontend.
//
// Guessed, unlike the attributes above: an entity id is a renameable
// display artifact, so the warning invites the user to check the target
// before writing to the printer. A `_slot_0_` id cannot have come from that
// naming scheme at all, so it is left to the card-position fallback rather
// than decremented into -1.
if (match && Number(match[2]) >= 1) {
return { boxId: Number(match[1]), slotId: Number(match[2]) - 1, guessed: true };
}
return { boxId: cardBoxIndex + 1, slotId: cardSlotIndex, guessed: true };
}
static _parsePercent(percentObj) {
if (!percentObj) return null;
const state = percentObj.state;
// "-" is a sentinel here too: Number("-") is NaN, so it was already
// rejected, but going through the shared helper keeps the set in one place.
if (isSentinel(state)) return null;
const n = Number(String(state).trim());
if (Number.isNaN(n) || !Number.isFinite(n)) return null;
return Math.max(0, Math.min(100, n));
}
// Returns one of four literals, never telemetry -- but the consumers run the
// result through _sanitizeColor anyway. HTML escaping does not neutralise a
// semicolon, so any value interpolated into a `style` attribute would parse a
// second declaration; the whitelist is what makes that unreachable.
static _getHumidityColor(humidityStr) {
if (!humidityStr || humidityStr === "-") return '#64b5f6'; // default blue
const match = String(humidityStr).match(/(\d+\.?\d*)/);
if (!match) return '#64b5f6';
const value = parseFloat(match[1]);
if (value < 40) return '#4caf50'; // Green (0-39%) - Ideal
if (value < 60) return '#ff9800'; // Orange (40-59%) - Attention
return '#f44336'; // Red (60-100%) - Critical
}
static getStubConfig() {
const cfg = {
name: "CFS",
view_mode: "full",
show_type_in_mini: false,
external_filament: "",
external_color: "",
external_percent: "",
};
for (let box = 0; box < 4; box += 1) {
cfg[`box${box}_temp`] = "";
cfg[`box${box}_humidity`] = "";
for (let slot = 0; slot < 4; slot += 1) {
cfg[`box${box}_slot${slot}_filament`] = "";
cfg[`box${box}_slot${slot}_color`] = "";
cfg[`box${box}_slot${slot}_percent`] = "";
}
}
return cfg;
}
static getConfigElement() {
return document.createElement(EDITOR_TAG);
}
setConfig(config) {
this._cfg = { ...KCFSCard.getStubConfig(), ...KCFSCard._migrateConfig(config) };
if (!this._root) {
this._root = this.attachShadow({ mode: "open" });
}
// _render() wipes #content, so the gate must not then decide nothing changed
// and skip repopulating it -- that would leave the card blank after an edit.
this._snapshot = null;
// Different entities may mean a different printer. Bumping the generation
// invalidates a _resolveDeviceId() that is already awaiting callWS: it
// captured the previous config's entity ids before the await, and writing
// its answer back would target the old printer for the rest of the session.
this._deviceId = undefined;
this._deviceIdError = null;
this._deviceIdGeneration = (this._deviceIdGeneration || 0) + 1;
this._deviceIdPending = false;
this._deviceIdRetried = false;
this._statusEid = null;
this._render();
}
// i18n helpers -------------------------------------------------------
_resolveLanguage() {
return _resolveLang(this._hass);
}
_t(key, vars) {
return _translate(this._hass, "cfs_card", CFS_TRANSLATIONS, key, vars);
}
// ---------------------------------------------------------------------
set hass(hass) {
this._hass = hass;
// Translated strings are not part of the snapshot, so when they arrive the
// gate has to be reset and the render forced.
_requestI18n(this, hass, () => { this._snapshot = null; this._update(); });
// HA does not guarantee hass.entities is populated on the first assignment.
// A resolution that failed against an empty registry caches null, and null
// is not undefined, so nothing would ever retry it. Reopen the question once
// the registry actually knows about one of our entities -- but only once.
// A card that legitimately cannot resolve (entities spanning two printers,
// or an entity with no device at all) also sits at null with a populated
// registry, and retrying it on every telemetry frame flipped the edit
// buttons between pencil and lock for the whole session.
if (
this._deviceId === null
&& !this._deviceIdPending
&& !this._deviceIdRetried
&& this._registryHasEntities()
) {
this._deviceIdRetried = true;
this._deviceId = undefined;
this._deviceIdError = null;
}
// _isPrinterBusy() is a sync cache read, so the device id has to be resolved
// out of band once; re-render when it lands so the lock icon settles.
if (this._deviceId === undefined && !this._deviceIdPending) {
this._deviceIdPending = true;
// .catch as well as .then: _resolveDeviceId can reject (a malformed config,
// a hass assigned before setConfig), and an unhandled rejection left
// _deviceIdPending true forever, so both gates here refused to retry and
// the card could never resolve while still rendering its buttons enabled.
// Generation-guarded out here too, not only inside _resolveDeviceId.
// `hass` can be assigned before setConfig, so the first call rejects on
// the missing config; setConfig then runs synchronously, bumps the
// generation and clears _deviceId, and the queued rejection would
// otherwise land afterwards and pin the *new* config to
// "toast_no_device" with its edit buttons locked. Clearing the pending
// flag is gated for the mirror-image reason: a stale run clearing it
// would let a second resolution start alongside the current one.
// Safe to skip, because setConfig has already reset it.
const generation = this._deviceIdGeneration || 0;
const isCurrent = () => (this._deviceIdGeneration || 0) === generation;
this._resolveDeviceId()
.catch((err) => {
console.warn("k-cfs-card: device resolution failed", err);
if (!isCurrent()) return;
this._deviceIdError = "toast_no_device";
this._deviceId = null;
})
.finally(() => {
if (!isCurrent()) return;
this._deviceIdPending = false;
this._updateIfChanged();
});
}
this._updateIfChanged();
}
_render() {
if (!this._root) return;
const modeClass = KCFSCard._modeClass(this._cfg.view_mode);
const style = `
/* inherit HA fonts & typography */
:host {
font: inherit;
color: var(--primary-text-color);
display: block !important;
height: auto !important;
contain: none !important;
position: relative;
z-index: 1;
}
/* unify horizontal padding so right edges line up */
:host { --row-xpad: 6px; }
ha-card {
overflow: visible !important;
display: block !important;
height: auto !important;
min-height: auto !important;
max-height: none !important;
contain: none !important;
position: relative;
z-index: 1;
}
ha-card.compact-mode,
ha-card.normal-mode {
height: auto !important;
overflow: visible !important;
}
#content {
padding: 14px;
}
.card {
border-radius: var(--ha-card-border-radius, 12px);
background: var(--card-background-color);
color: var(--primary-text-color);
box-shadow: var(--ha-card-box-shadow, 0 2px 4px rgba(0,0,0,.2));
padding: 10px var(--row-xpad) 10px var(--row-xpad);
display: flex;
flex-direction: column;
gap: 6px;
}
/* === NORMAL MODE === */
.normal-mode {}
.header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 12px;
}
.title-section {
display: flex;
flex-direction: column;
gap: 4px;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 500;
}
.env-info {
font-size: 11px;
background: rgba(var(--rgb-primary-text-color), 0.08);
padding: 6px 10px;
border-radius: 12px;
color: var(--secondary-text-color);
}
.unit-selector {
display: flex;
gap: 8px;
margin-bottom: 20px;
background: rgba(var(--rgb-primary-text-color), 0.05);
padding: 4px;
border-radius: 14px;
width: fit-content;
}
.unit-btn {
padding: 6px 16px;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
border: none;
color: var(--secondary-text-color);
background: transparent;
}
.unit-btn.active {
background: rgba(var(--rgb-primary-text-color), 0.1);
color: var(--primary-text-color);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
}
.spool-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.spool-card {
background: rgba(var(--rgb-primary-text-color), 0.04);
border-radius: var(--ha-card-border-radius, 18px);
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
border: 1px solid transparent;
transition: all 0.3s ease;
cursor: pointer;
}
.spool-card:hover {
background: rgba(var(--rgb-primary-text-color), 0.06);
}
.spool-card.active {
background: rgba(var(--rgb-primary-text-color), 0.08);
border: 1px solid rgba(var(--rgb-primary-color), 0.3);
box-shadow: 0 0 20px rgba(var(--rgb-primary-color), 0.2);
}
.ring-container {
width: 80px;
height: 80px;
border-radius: 50%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
margin-bottom: 10px;
}
.ring-outer {
width: 100%;
height: 100%;
border-radius: 50%;
position: absolute;
background: conic-gradient(
var(--spool-color) var(--spool-pct),
rgba(var(--rgb-primary-text-color), 0.08) 0
);
mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
-webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 33px, black 34px);
}
.ring-inner {
width: 66px;
height: 66px;
background: transparent;
border-radius: 50%;
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.spool-label {
font-size: 10px;
color: var(--secondary-text-color);
text-transform: uppercase;
}
.spool-pct {
font-size: 16px;
font-weight: 700;
}
.material-name {
font-size: 13px;
font-weight: 600;
text-align: center;
}
.color-name {
font-size: 11px;
color: var(--secondary-text-color);
text-align: center;
}
.status-badge {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
background: var(--success-color, #4caf50);
border-radius: 50%;
box-shadow: 0 0 8px var(--success-color, #4caf50);
animation: pulse-badge 2s infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.7; transform: scale(1.1); }
}
/* === COMPACT MODE === */
.compact-mode {
padding: 14px;
}
.compact-mode .header {
margin-bottom: 12px;
}
.cfs-rows {
display: flex;
flex-direction: column;
gap: 10px;
}
.cfs-row {
display: flex;
align-items: center;
gap: 12px;
}
.cfs-label {
width: 48px;
font-size: 11px;
color: var(--secondary-text-color);
font-weight: 600;
}
.spools-inline {
display: flex;
gap: 10px;
flex: 1;
}
.spool-mini {
width: 34px;
height: 34px;
border-radius: 50%;
position: relative;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s;
}
.spool-mini:hover {
transform: scale(1.05);
}
.spool-mini::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(
var(--spool-color) var(--spool-pct),
rgba(var(--rgb-primary-text-color), 0.08) 0
);
mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
-webkit-mask: radial-gradient(circle closest-side at 50% 50%, transparent 12px, black 13px);
}
.spool-mini::after {
content: '';
position: absolute;
inset: 4px;
background: transparent;
border-radius: 50%;
z-index: 1;
}
.spool-mini span {
position: relative;
z-index: 2;
}
.spool-mini.active {
box-shadow: 0 0 10px var(--spool-color);
}
.spool-mini.active::after {
inset: 3px;
}
.spool-mini-wrapper {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.spool-mini-type {
font-size: 8px;
color: var(--secondary-text-color);
text-transform: uppercase;
font-weight: 600;
max-width: 40px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.env-mini {
width: 56px;
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 10px;
line-height: 1.4;
gap: 2px;
}
.env-mini .temp {
color: #ffb74d;
font-weight: 600;
}
.env-mini .hum {
font-weight: 600;
/* Cor aplicada dinamicamente via inline style */
}
/* === EXTERNAL SECTION === */
.external-section {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid rgba(var(--rgb-primary-text-color), 0.08);
}
.external-normal {
position: relative;
background: rgba(var(--rgb-primary-text-color), 0.03);
border-radius: 16px;
padding: 12px 16px;
display: flex;
align-items: center;
gap: 12px;
}
.ext-icon {
width: 30px;
height: 30px;
background: var(--primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: bold;
color: white;
}
.ext-info {
flex-grow: 1;
}
.ext-name {
font-size: 12px;
font-weight: 600;
}
.ext-bar {
height: 4px;
background: rgba(var(--rgb-primary-text-color), 0.1);
border-radius: 2px;
margin-top: 6px;
overflow: hidden;
}
.ext-fill {
height: 100%;
background: var(--primary-color);
transition: width 0.3s ease;
}
.ext-percent {
font-size: 12px;
color: var(--secondary-text-color);
font-weight: 600;
}
.external-compact {
position: relative;
display: flex;
align-items: center;
gap: 10px;
}
.ext-dot {
width: 34px;
height: 34px;
border-radius: 50%;
background: conic-gradient(var(--primary-color) 100%, rgba(var(--rgb-primary-text-color), 0.1) 0);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.ext-compact-info {
flex: 1;
}
.ext-compact-info div:first-child {
font-size: 12px;
font-weight: 600;
}
.ext-compact-info div:last-child {
font-size: 10px;
color: var(--secondary-text-color);
}
/* === BOX VIEW === */
.box-view {
position: relative;
width: 100%;
margin: 4px 0;
}
.box-image {
display: block;
width: 100%;
height: auto;
}
/* Four evenly spaced bays across the unit's glass section. The geometry is
tied to cfs_box.webp, which is why the mode is gated on a 4-slot box. */
.bays {
position: absolute;
/* Measured against cfs_box.webp: the enclosure interior spans 7.5%-92.5%
horizontally with its dividers at 29/50/71%, so four equal columns
land one per bay. The height stops at the glass floor so a spool does
not spill onto the drawer below. */
top: 7%;
left: 7.5%;
width: 85%;
height: 45%;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.bay {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
}
.bay + .bay {
/* Theme variable rather than a hardcoded white, which inverted badly in
light themes. */
border-left: 1px solid var(--divider-color);
}
.bay-spool {
width: 50%;
aspect-ratio: 1;
border-radius: 50%;
background: var(--spool-color);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.25) inset;
opacity: 0.85;
}
.bay-label {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 4px;
padding: 1px 4px;
border-radius: 4px;
/* Legible over a photo in either theme. */
background: var(--card-background-color);
color: var(--primary-text-color);
font-size: 10px;
line-height: 1.2;
opacity: 0.92;
}
.bay-type { font-weight: 500; }
.bay-pct { color: var(--secondary-text-color); }
/* === EDIT AFFORDANCE === */
.edit-btn, .edit-btn-mini {
position: absolute;
top: 4px;
left: 4px;
z-index: 3;
display: grid;
place-items: center;
padding: 0;
border: none;
border-radius: 50%;
background: var(--secondary-background-color);
color: var(--primary-text-color);
cursor: pointer;
opacity: 1;
transition: opacity 120ms ease-in-out;
}
.edit-btn { width: 26px; height: 26px; }
.edit-btn-mini { width: 18px; height: 18px; top: -2px; left: -2px; }
.edit-btn ha-icon { --mdc-icon-size: 16px; }
.edit-btn-mini ha-icon { --mdc-icon-size: 12px; }
.edit-btn[aria-disabled="true"], .edit-btn-mini[aria-disabled="true"] {
cursor: not-allowed;
opacity: 0.45;
}
/* Reveal on hover only where hovering exists. A wall tablet is the primary
HA surface, and there the button has to be visible without one. */
@media (hover: hover) {
/* The [aria-disabled] selectors are repeated here on purpose: they are
more specific than a bare .edit-btn, and a media query adds no
specificity, so without them a locked button stays visible while the
editable ones hide. */
.edit-btn, .edit-btn-mini,
.edit-btn[aria-disabled="true"], .edit-btn-mini[aria-disabled="true"] { opacity: 0; }
.spool-card:hover .edit-btn,
.spool-mini-wrapper:hover .edit-btn-mini,
.bay:hover .edit-btn-mini,
.external-normal:hover .edit-btn,
.external-compact:hover .edit-btn { opacity: 1; }
}
.edit-btn:focus-visible, .edit-btn-mini:focus-visible {
opacity: 1;
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
/* === EDIT DIALOG === */
.edit-overlay {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: 16px;
background: rgba(0, 0, 0, 0.55);
}
.edit-dialog {
width: min(420px, 100%);
max-height: 85vh;
overflow-y: auto;
padding: 16px;
border-radius: var(--ha-card-border-radius, 12px);
background: var(--card-background-color, var(--ha-card-background));
color: var(--primary-text-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.edit-dialog h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 500;
}
.edit-target {
margin-bottom: 12px;
font-size: 12px;
color: var(--secondary-text-color);
}
.edit-warning {
margin-bottom: 12px;
padding: 8px 10px;
border-radius: 6px;
background: var(--warning-color, #ffa726);
color: #000;
font-size: 12px;
}
.colour-row { margin: 12px 0 4px; }
.colour-row > label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--secondary-text-color);
}
.colour-inputs { display: flex; gap: 8px; align-items: center; }
.colour-inputs input[type="color"] {
width: 44px;
height: 34px;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: none;
cursor: pointer;
}
.colour-inputs input[type="text"] {
flex: 1;
min-width: 0;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: var(--secondary-background-color);
color: var(--primary-text-color);
font-family: monospace;
}
.colour-inputs input:disabled { opacity: 0.5; cursor: not-allowed; }
.presets { margin-top: 12px; }
.presets > label {
display: block;
margin-bottom: 6px;
font-size: 13px;
color: var(--secondary-text-color);
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.swatch {
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--divider-color);
border-radius: 50%;
cursor: pointer;
}
.swatch.custom { border-style: dashed; }
.swatch:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 1px;
}
.preset-save { display: flex; gap: 8px; }
.preset-save input {
flex: 1;
min-width: 0;
padding: 8px;
border: 1px solid var(--divider-color);
border-radius: 6px;
background: var(--secondary-background-color);
color: var(--primary-text-color);
}
.preset-hint {
margin-top: 6px;
font-size: 11px;
color: var(--secondary-text-color);
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 20px;
}
.dialog-btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
}
.dialog-btn.secondary {
background: var(--secondary-background-color);
color: var(--primary-text-color);
}
.dialog-btn.primary {
background: var(--primary-color);
color: var(--text-primary-color, #fff);
}
.dialog-btn[disabled] { opacity: 0.6; cursor: default; }
/* === TOAST === */
.cfs-toast {
position: absolute;
left: 50%;
bottom: 12px;
transform: translateX(-50%);
/* Above .edit-overlay (100). Both _showToast and _showEditDialog append
to the same root, so at 10 the overlay's rgba backdrop painted over
every toast raised from the dialog: preset saved, preset deleted,
name required and invalid colour were all invisible. */
z-index: 110;
max-width: calc(100% - 24px);
padding: 8px 14px;
border-radius: 6px;
background: var(--primary-color);
color: var(--text-primary-color, #fff);
font-size: 13px;
line-height: 1.3;
text-align: center;
box-shadow: var(--ha-card-box-shadow, 0 2px 6px rgba(0, 0, 0, 0.3));
pointer-events: none;
}
.no-data {
text-align: center;
color: var(--secondary-text-color);
padding: 20px;
}
`;
this._root.innerHTML = `