ui: group the eSIM chip info into boxes and wrap values nicely

The chip view was one long flat list spread over the full height.

- esimRenderChip split into esimChipSectionRows (object or RAT rule list
  -> rows) and esimChipBox (titled box, skipped when empty); the boxes
  are laid out on a grid: EID, EUICCInfo1, Addresses and the rules
  authorisation table in the left column, the larger EUICCInfo2 in the
  wider right-hand box (single column on narrow screens).  Rows use a
  narrower label column (w-36).
- Values wrap with break-words + min-w-0 instead of break-all: text
  breaks at spaces first and long hex/AID tokens only when they cannot
  fit (same for the profile metadata rows).
- style.css rebuilt (grid-cols-1, lg:grid-cols-3, lg:col-span-1/2/3,
  break-words).
- tests: the new helpers, the grid/box layout and the wrapping check;
  help EN/RU note the grouping; sw.js simple-v232.
This commit is contained in:
2026-09-22 00:27:31 +03:00
parent d3f5aaa443
commit a7ec67b19c
6 changed files with 128 additions and 32 deletions
+1 -1
View File
@@ -497,7 +497,7 @@
<h3 id="esim" class="text-lg font-medium mb-2">8.8 eSIM / LPA (локальные операции)</h3>
<p class="text-sm mb-2">Для eUICC (SGP.22/SGP.32) подвкладка <strong>&laquo;eSIM&raquo;</strong> читает чип и управляет установленными профилями через локальный интерфейс ES10 (через pySim, без обращения к SM-DP+):</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Чип</strong> — EID, EUICCInfo1/2 со всеми декодированными полями (версия профиля, SVN, прошивка, расширенные ресурсы карты — число установленных приложений и свободная энергонезависимая/энергозависимая память —, битовые списки возможностей UICC и RSP, идентификаторы CI PKI, категория, запрещённые PPR, версия PP, номер аккредитации SS, данные сертификации), настроенные адреса SM-DP+ по умолчанию / корневого DS и <strong>таблица авторизации правил</strong> (ES10b GetRat: идентификаторы PPR, разрешённые операторы, флаг согласия). Неизвестные TLV сохраняются как сырой hex.</li>
<li><strong>Чип</strong> — EID, EUICCInfo1/2 со всеми декодированными полями (версия профиля, SVN, прошивка, расширенные ресурсы карты — число установленных приложений и свободная энергонезависимая/энергозависимая память —, битовые списки возможностей UICC и RSP, идентификаторы CI PKI, категория, запрещённые PPR, версия PP, номер аккредитации SS, данные сертификации), настроенные адреса SM-DP+ по умолчанию / корневого DS и <strong>таблица авторизации правил</strong> (ES10b GetRat: идентификаторы PPR, разрешённые операторы, флаг согласия). Неизвестные TLV сохраняются как сырой hex. Поля сгруппированы в блоки — EID, EUICCInfo1, адреса и таблица авторизации правил слева, более крупный EUICCInfo2 в правом блоке (на узких экранах — одна колонка).</li>
<li><strong>Профили</strong> — все профили с состоянием (включён/отключён), ником, оператором, именем, ICCID, ISD-P AID, классом, владельцем и типом значка — сам значок показывается в натуральную величину рядом с метаданными, а строка <strong>Icon</strong> содержит его тип и размер данных в байтах. Кнопки <strong>&laquo;Включить&raquo;</strong>/<strong>&laquo;Отключить&raquo;</strong> переключают профиль; карта обычно сначала отправляет REFRESH (виден в журнале проактивных команд) — терминальный ответ завершает переключение, после чего сессия карты переинициализируется как при equip: ICCID, сетевое состояние и все кэшированные данные перечитываются, а запрошенное состояние проверяется по перечитанному списку профилей.</li>
<li><strong>Уведомления</strong> — список ожидающих уведомлений только для чтения (номер последовательности, операция, адрес, ICCID); ничего не обрабатывается и не удаляется.</li>
</ul>
+1 -1
View File
@@ -497,7 +497,7 @@
<h3 id="esim" class="text-lg font-medium mb-2">8.8 eSIM / LPA (local operations)</h3>
<p class="text-sm mb-2">For an eUICC (SGP.22/SGP.32) the <strong>eSIM</strong> pill reads the chip and manages the installed profiles through the local ES10 interface (via pySim, no SM-DP+ contact):</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Chip</strong> — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the <strong>rules authorisation table</strong> (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex.</li>
<li><strong>Chip</strong> — EID, EUICCInfo1/2 with every field decoded (profile version, SVN, firmware, extended card resource — installed applications and free non-volatile/volatile memory —, UICC and RSP capability bit lists, CI PKI identifiers, category, forbidden profile policy rules, PP version, SS accreditation number, certification data), the configured default SM-DP+ / root DS addresses, and the <strong>rules authorisation table</strong> (ES10b GetRat: PPR IDs, allowed operators, consent flag). Unknown TLVs are preserved as raw hex. The fields are grouped into boxes — EID, EUICCInfo1, Addresses and the rules authorisation table on the left, the larger EUICCInfo2 in the right-hand box (a single column on narrow screens).</li>
<li><strong>Profiles</strong> — every profile with its state (enabled/disabled), nickname, provider, name, ICCID, ISD-P AID, class, owner and icon type — the icon image itself is shown at its natural size next to the metadata, and the <strong>Icon</strong> row carries its type and data size in bytes. <strong>Enable</strong>/<strong>Disable</strong> switches a profile; the card usually sends REFRESH first (logged in the proactive command log) — the terminal response completes the switch and the card session is then re-initialized like an equip, so the ICCID, network state and every cached card view are re-read, and the requested state is verified against the re-read profile list.</li>
<li><strong>Notifications</strong> — read-only list of pending notifications (sequence number, operation, address, ICCID); nothing is processed or removed.</li>
</ul>
+44 -28
View File
@@ -1665,41 +1665,57 @@ function esimIconDataUrl(p) {
return 'data:' + mime + ';base64,' + btoa(bin);
}
// One chip-info section -> rows (the RAT is a list of rules).
function esimChipSectionRows(data) {
if (!data) return [];
if (Array.isArray(data)) {
const rows = [];
data.forEach((item, i) => {
rows.push(...esimFieldRows(item, t('Rule') + ' ' + (i + 1)));
});
return rows;
}
return esimFieldRows(data, '');
}
// One bordered chip-info box; no rows -> no box.
function esimChipBox(title, rows) {
if (!rows || !rows.length) return '';
let html = '<div class="border border-gray-200 dark:border-slate-700 rounded p-2 min-w-0">';
html += '<div class="mb-1 text-xs font-semibold text-gray-600 dark:text-slate-300">' + esc(title) + '</div>';
for (const [k, v] of rows) {
html += '<div class="flex gap-2 mb-0.5">';
if (k) html += '<span class="w-36 shrink-0 text-right text-gray-500 dark:text-slate-400 break-words">' + esc(k) + '</span>';
html += '<span class="font-mono break-words min-w-0">' + esc(v) + '</span></div>';
}
return html + '</div>';
}
function esimRenderChip() {
const el = document.getElementById('esim-chip');
if (!el) return;
const c = _esimChip || {};
const rows = [];
if (c.eid) rows.push(['EID', esimGroupEid(c.eid)]);
const sections = [
['EUICCInfo1', c.info1], ['EUICCInfo2', c.info2],
['Addresses', c.addresses], ['Rules authorisation table', c.rat],
];
for (const [name, data] of sections) {
if (!data || (Array.isArray(data) && !data.length)) continue;
rows.push(['§ ' + t(name), '']);
if (Array.isArray(data)) {
data.forEach((item, i) => {
rows.push(...esimFieldRows(item, t('Rule') + ' ' + (i + 1)));
});
} else {
rows.push(...esimFieldRows(data, ''));
}
}
let html = '';
for (const [k, v] of rows) {
if (k.startsWith('§ ')) {
html += '<div class="mt-2 mb-0.5 font-semibold text-gray-600 dark:text-slate-300">' + esc(k.slice(2)) + '</div>';
continue;
}
html += '<div class="flex gap-2 mb-0.5"><span class="w-56 shrink-0 text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
'<span class="font-mono break-all">' + esc(v) + '</span></div>';
const left = [
c.eid ? esimChipBox('EID', [['', esimGroupEid(c.eid)]]) : '',
esimChipBox('EUICCInfo1', esimChipSectionRows(c.info1)),
esimChipBox(t('Addresses'), esimChipSectionRows(c.addresses)),
esimChipBox(t('Rules authorisation table'), esimChipSectionRows(c.rat)),
].filter(Boolean);
const right = esimChipBox('EUICCInfo2', esimChipSectionRows(c.info2));
if (!left.length && !right) {
el.innerHTML = '<span class="text-gray-400">' + esc(t('No data')) + '</span>';
return;
}
let html = '<div class="grid grid-cols-1 lg:grid-cols-3 gap-3 items-start">';
html += '<div class="' + (right ? 'lg:col-span-1' : 'lg:col-span-3') + ' space-y-3 min-w-0">' +
left.join('') + '</div>';
if (right) html += '<div class="lg:col-span-2 min-w-0">' + right + '</div>';
html += '</div>';
const errs = Object.keys(c.errors || {});
if (errs.length) {
html += '<div class="text-xs text-yellow-600 dark:text-yellow-500 mt-1">' + esc(t('Not available')) + ': ' + esc(errs.join(', ')) + '</div>';
}
el.innerHTML = html || '<span class="text-gray-400">' + esc(t('No data')) + '</span>';
el.innerHTML = html;
}
function esimRenderProfiles() {
@@ -1724,8 +1740,8 @@ function esimRenderProfiles() {
esc(t(enabled ? 'Disable' : 'Enable')) + '</button></div>';
let rowsHtml = '';
for (const [k, v] of esimProfileRows(p)) {
rowsHtml += '<div class="flex gap-2"><span class="w-40 shrink-0 text-right text-gray-500 dark:text-slate-400 break-all">' + esc(k) + '</span>' +
'<span class="font-mono break-all">' + esc(v) + '</span></div>';
rowsHtml += '<div class="flex gap-2"><span class="w-40 shrink-0 text-right text-gray-500 dark:text-slate-400 break-words">' + esc(k) + '</span>' +
'<span class="font-mono break-words min-w-0">' + esc(v) + '</span></div>';
}
const iconUrl = esimIconDataUrl(p);
html += '<div class="flex gap-3 items-start">';
+46
View File
@@ -612,6 +612,10 @@ video {
}
}
.pointer-events-none {
pointer-events: none;
}
.visible {
visibility: visible;
}
@@ -1036,6 +1040,10 @@ video {
list-style-type: none;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -1159,6 +1167,12 @@ video {
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.space-y-4 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(1rem * var(--tw-space-y-reverse));
}
.overflow-auto {
overflow: auto;
}
@@ -1185,6 +1199,10 @@ video {
white-space: pre-wrap;
}
.break-words {
overflow-wrap: break-word;
}
.break-all {
word-break: break-all;
}
@@ -1850,6 +1868,11 @@ video {
color: rgb(251 191 36 / var(--tw-text-opacity, 1));
}
.dark\:text-amber-500:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(245 158 11 / var(--tw-text-opacity, 1));
}
.dark\:text-blue-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
@@ -1905,6 +1928,11 @@ video {
color: rgb(100 116 139 / var(--tw-text-opacity, 1));
}
.dark\:text-slate-600:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity, 1));
}
.dark\:text-white:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity, 1));
@@ -1947,6 +1975,24 @@ video {
.dark\:hover\:text-slate-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
@media (min-width: 1024px) {
.lg\:col-span-1 {
grid-column: span 1 / span 1;
}
.lg\:col-span-2 {
grid-column: span 2 / span 2;
}
.lg\:col-span-3 {
grid-column: span 3 / span 3;
}
.lg\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}/* ===== Theme contrast adjustments ===== */
/* Light theme: darken softer (muted) text one step for higher contrast */
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v231';
const CACHE = 'simple-v232';
const URLS = [
'index.html',
'help.html',
+35 -1
View File
@@ -24,7 +24,7 @@ function extractFunc(src, name) {
let code = '';
for (const fn of ['esimLabel', 'esimGroupEid', 'esimFieldRows', 'esimResultText',
'esimStateLabel', 'esimOperationsText', 'esimProfileRows', 'esimIconDataUrl',
'esimSwitchStatus']) {
'esimChipSectionRows', 'esimChipBox', 'esimSwitchStatus']) {
code += extractFunc(html, fn) + '\n';
}
for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
@@ -32,6 +32,7 @@ for (const c of ['ESIM_CHIP_LABELS', 'ESIM_RESULT_KEYS']) {
.replace('const ', 'var ') + '\n';
}
code += 'function t(s){return s;}\n';
code += 'function esc(s){return String(s);}\n';
eval(code);
test('esimGroupEid groups the hex digits in fours', () => {
@@ -153,6 +154,39 @@ test('the profile card renders the icon image next to the rows', () => {
assert.match(fn, /<img src=/);
});
test('esimChipSectionRows builds rows for objects and RAT rule lists', () => {
assert.deepStrictEqual(esimChipSectionRows({ svn: '2.2.2' }), [['SVN', '2.2.2']]);
assert.deepStrictEqual(esimChipSectionRows(null), []);
assert.deepStrictEqual(esimChipSectionRows([{ ppr_ids: ['ppr1'] }]),
[['Rule 1 / PPR IDs', 'ppr1']]);
});
test('esimChipBox renders a titled box and skips empty sections', () => {
assert.strictEqual(esimChipBox('EUICCInfo1', []), '');
assert.strictEqual(esimChipBox('EUICCInfo1', null), '');
const box = esimChipBox('EUICCInfo1', [['SVN', '2.2.2'], ['', 'raw-value']]);
assert.match(box, /EUICCInfo1/);
assert.match(box, /SVN/);
assert.match(box, /2\.2\.2/);
assert.match(box, /raw-value/);
});
test('the chip boxes prefer wrapping at spaces over breaking words', () => {
const box = esimChipBox('EUICCInfo1', [['SVN', '2.2.2']]);
assert.match(box, /break-words/);
assert.doesNotMatch(box, /break-all/);
const render = extractFunc(html, 'esimRenderProfiles');
assert.doesNotMatch(render, /break-all/);
});
test('the chip view groups the sections into a grid', () => {
const fn = extractFunc(html, 'esimRenderChip');
assert.match(fn, /lg:grid-cols-3/);
assert.match(fn, /lg:col-span-2/);
assert.match(fn, /esimChipBox\('EUICCInfo2'/);
assert.match(fn, /esimChipSectionRows/);
});
test('the eSIM pill is wired into the Phone simulator tab', () => {
assert.ok(html.includes('data-phone-sub="esim"'));
assert.ok(html.includes('id="phone-sub-esim"'));