Classic theme after the 2004 Mac app; header and dialog buttons as icons
- A Classic theme beside Dark and Light: brushed-metal toolbar and status bar, a pale blue-grey source list, Aqua blue for whatever is selected, red unread badges, a striped table with blue titles, a blue bar behind the item's title, and Lucida Grande. The theme button steps through all three and remembers the choice. - The feed and OPML headers' buttons (scan, download latest, mark all read, settings, unsubscribe) and the Settings, feed settings and Download latest dialogs' buttons (save, download, cancel) are icons, with the words in title and aria-label. - Tests: the theme button reaches Classic and it survives a reload; the header buttons are found by action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016HdTEWQNrzyFULijigkmMn
This commit is contained in:
@@ -12,6 +12,9 @@ The long form, with what was wrong before and how it was found, is in
|
||||
|
||||
### Added
|
||||
|
||||
- A Classic theme after the 2004 Mac app, beside Dark and Light: brushed-metal toolbar, Aqua
|
||||
blue selection, red unread badges, a striped table and Lucida Grande. The theme button steps
|
||||
through all three and remembers the choice.
|
||||
- A toolbar across the top, after the original iPodderX: add and unsubscribe, play, mark read
|
||||
and keep for the selected item, scan, a search box for what is showing, and Settings and Log.
|
||||
- Directory, Popular and All Subscriptions at the top of the feed list, opening in the main pane.
|
||||
@@ -28,8 +31,10 @@ The long form, with what was wrong before and how it was found, is in
|
||||
items show without pressing Scan.
|
||||
- A file deleted to save space, or by hand, looks as if it was never downloaded: no "reaped"
|
||||
label, just the Download button. The retention summary says "deleted", not "reaped".
|
||||
- The Files pane and an item's own buttons are icons, with the words in their tooltips: save,
|
||||
delete, view, download, mark read, keep, and open the original.
|
||||
- Buttons are icons, with the words in their tooltips: the Files pane (save, delete, view,
|
||||
download), an item's own buttons (mark read, keep, open the original), the feed header (scan,
|
||||
download latest, mark all read, settings, unsubscribe), and the Settings, feed settings and
|
||||
Download latest dialogs (save, download, cancel).
|
||||
|
||||
### Security
|
||||
|
||||
|
||||
@@ -29,6 +29,18 @@ test('the theme toggle actually changes the theme', async ({ page }) => {
|
||||
.not.toBe(before);
|
||||
});
|
||||
|
||||
test('the theme button steps through dark, light and classic, and remembers', async ({ page }) => {
|
||||
const theme = () => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
for (let i = 0; i < 3 && (await theme()) !== 'classic'; i++) await page.locator('#theme').click();
|
||||
expect(await theme()).toBe('classic');
|
||||
await expect(page.locator('#theme')).toHaveAttribute('title', /Classic.*Click for Dark/);
|
||||
|
||||
await page.reload();
|
||||
await expect.poll(theme).toBe('classic');
|
||||
// The 2004 Mac app set its type in Lucida Grande.
|
||||
expect(await page.evaluate(() => getComputedStyle(document.body).fontFamily)).toContain('Lucida Grande');
|
||||
});
|
||||
|
||||
test('settings opens and saves the global schedule', async ({ page }) => {
|
||||
await page.locator('#prefs').click();
|
||||
await expect(page.locator('#modal.on')).toBeVisible();
|
||||
@@ -136,7 +148,7 @@ test('the filter tabs change what is listed', async ({ page }) => {
|
||||
|
||||
test('a feed URL is editable and has a copy button', async ({ page }) => {
|
||||
await page.getByText('Test Show').click();
|
||||
await page.locator('.btn', { hasText: 'Settings' }).first().click();
|
||||
await page.locator('#content .acts [data-a="settings"]').click();
|
||||
await expect(page.locator('#surl')).toHaveValue(/show\.xml/);
|
||||
await expect(page.locator('#scopy')).toBeVisible();
|
||||
|
||||
@@ -239,7 +251,7 @@ test('marking an OPML subscription read covers the feeds inside it', async ({ pa
|
||||
await expect(folder.locator('.badge')).not.toHaveText('0');
|
||||
await folder.click();
|
||||
|
||||
await page.locator('#content .acts button', { hasText: 'Mark all read' }).click();
|
||||
await page.locator('#content .acts [data-a="read"]').click();
|
||||
await expect(folder.locator('.badge')).toHaveText('0');
|
||||
});
|
||||
|
||||
|
||||
115
web/index.html
115
web/index.html
@@ -44,6 +44,26 @@
|
||||
--bad:#b3402f;
|
||||
--shadow:0 8px 28px rgba(45,83,145,.14);
|
||||
}
|
||||
/* Classic: the 2004 Mac app. Colours here; the chrome it needs is at the end of the sheet. */
|
||||
:root[data-theme="classic"] {
|
||||
color-scheme:light;
|
||||
--bg:#ffffff;
|
||||
--panel:#e7ebf1; /* the source list's pale blue-grey */
|
||||
--panel2:#f2f2f2;
|
||||
--raise:#d5dce7;
|
||||
--line:#a9a9a9;
|
||||
--fg:#000000;
|
||||
--dim:#444444;
|
||||
--faint:#666666;
|
||||
--accent:#3875d7; /* Aqua selection blue */
|
||||
--accent2:#2a5db0;
|
||||
--ink:#ffffff;
|
||||
--good:#237a23;
|
||||
--warn:#a15f00;
|
||||
--bad:#c42b1c;
|
||||
--shadow:0 4px 16px rgba(0,0,0,.28);
|
||||
--r:5px;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
/* A rule that sets display beats the UA's [hidden], and several below do. */
|
||||
[hidden]{display:none!important}
|
||||
@@ -427,6 +447,54 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
#logbox .lv{width:auto}
|
||||
#logbox .tg{display:none}
|
||||
}
|
||||
|
||||
/* ---------- Classic: the 2004 Mac app ---------- */
|
||||
/* After the iPodderX screenshots: brushed-metal chrome, a pale blue-grey source list, Aqua blue
|
||||
for whatever is selected, red unread badges, a striped table and Lucida Grande. */
|
||||
:root[data-theme="classic"] body{
|
||||
font-family:"Lucida Grande","Lucida Sans Unicode","Lucida Sans",Geneva,Verdana,sans-serif;font-size:13px;
|
||||
}
|
||||
:root[data-theme="classic"] #topbar,
|
||||
:root[data-theme="classic"] #status,
|
||||
:root[data-theme="classic"] #player{background:linear-gradient(#ececec,#c9c9c9);border-color:#8e8e8e;color:#1a1a1a}
|
||||
:root[data-theme="classic"] #status{text-align:center}
|
||||
:root[data-theme="classic"] #sidebar{background:#e7ebf1;border-right-color:#a3a3a3}
|
||||
:root[data-theme="classic"] #files{background:#f4f4f4}
|
||||
:root[data-theme="classic"] .tgroup{background:none;border-color:#8e8e8e;border-radius:6px}
|
||||
:root[data-theme="classic"] .tgroup button+button{border-left-color:#8e8e8e}
|
||||
:root[data-theme="classic"] .tgroup button,
|
||||
:root[data-theme="classic"] .btn,
|
||||
:root[data-theme="classic"] .tabs{background:linear-gradient(#ffffff,#d9d9d9);border-color:#8e8e8e;color:#1a1a1a}
|
||||
:root[data-theme="classic"] .tabs{border:1px solid #8e8e8e;border-radius:6px}
|
||||
:root[data-theme="classic"] .tgroup button:hover:not(:disabled),
|
||||
:root[data-theme="classic"] .btn:hover{background:linear-gradient(#f5f9ff,#cbdbf4)}
|
||||
:root[data-theme="classic"] .btn.primary,
|
||||
:root[data-theme="classic"] .tabs button.on,
|
||||
:root[data-theme="classic"] .feed.sel,
|
||||
:root[data-theme="classic"] .place.sel{background:linear-gradient(#86b0ec,#3a73cf);border-color:#2d5eae;color:#fff}
|
||||
:root[data-theme="classic"] .feed.sel .txt small,
|
||||
:root[data-theme="classic"] .place.sel .ico{color:#e6eeff}
|
||||
:root[data-theme="classic"] #epSearch{border-radius:14px}
|
||||
:root[data-theme="classic"] .badge{background:#c9302c;color:#fff}
|
||||
:root[data-theme="classic"] .badge.zero{background:#b4bdc9;color:#fff}
|
||||
:root[data-theme="classic"] .ephead,
|
||||
:root[data-theme="classic"] .fhd{
|
||||
background:linear-gradient(#fbfbfb,#dcdcdc);color:#1a1a1a;text-transform:none;letter-spacing:0;font-size:11px;
|
||||
}
|
||||
:root[data-theme="classic"] .fhd{padding:3px 6px;border:1px solid #c2c2c2}
|
||||
:root[data-theme="classic"] .ephead{border-bottom-color:#a3a3a3}
|
||||
:root[data-theme="classic"] .ep{border-radius:0;margin:0}
|
||||
:root[data-theme="classic"] #eps .ep:nth-child(even){background:#edf3fe}
|
||||
:root[data-theme="classic"] .ep .t{color:#1c4fa8}
|
||||
:root[data-theme="classic"] .ep.read .t{color:#333}
|
||||
:root[data-theme="classic"] #eps .ep.sel{background:#3875d7;border-color:#3875d7}
|
||||
:root[data-theme="classic"] .ep.sel .t,
|
||||
:root[data-theme="classic"] .ep.sel .fd,
|
||||
:root[data-theme="classic"] .ep.sel .date,
|
||||
:root[data-theme="classic"] .ep.sel .line,
|
||||
:root[data-theme="classic"] .ep.sel .st,
|
||||
:root[data-theme="classic"] .ep.sel .fl{color:#fff}
|
||||
:root[data-theme="classic"] .dt{background:linear-gradient(#80aae6,#3f78cf);color:#fff;padding:6px 12px;border-radius:4px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -692,11 +760,11 @@ function renderFeed(){
|
||||
${f.group?`<div class="sub">From the OPML subscription <b>${esc(f.group)}</b></div>`:''}
|
||||
</div>
|
||||
<div class="acts">
|
||||
<button class="btn primary" data-a="scan"><i>⟳</i>Scan now</button>
|
||||
<button class="btn" data-a="dl"><i>↓</i>Download latest…</button>
|
||||
<button class="btn" data-a="read"><i>✓</i>Mark all read</button>
|
||||
<button class="btn" data-a="settings"><i>⚙</i>Settings</button>
|
||||
<button class="btn danger last" data-a="rm"><i>✕</i>Unsubscribe</button>
|
||||
<button class="btn ico primary" data-a="scan" title="Scan now" aria-label="Scan now">⟳</button>
|
||||
<button class="btn ico" data-a="dl" title="Download latest…" aria-label="Download latest">⤓</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">✓✓</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">⚙</button>
|
||||
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">✕</button>
|
||||
</div>
|
||||
</div>` : `
|
||||
<div class="fhead slim">
|
||||
@@ -748,10 +816,10 @@ function renderGroup(f,kids){
|
||||
${gone?`<div class="sub" style="color:var(--warn)">${gone} feed${gone===1?' is':'s are'} no longer
|
||||
listed in this OPML but kept because ${gone===1?'it has':'they have'} downloads.</div>`:''}
|
||||
<div class="acts">
|
||||
<button class="btn primary" data-a="scan"><i>⟳</i>Re-read OPML</button>
|
||||
<button class="btn" data-a="read"><i>✓</i>Mark all read</button>
|
||||
<button class="btn" data-a="settings"><i>⚙</i>Settings</button>
|
||||
<button class="btn danger last" data-a="rm"><i>✕</i>Unsubscribe</button>
|
||||
<button class="btn ico primary" data-a="scan" title="Re-read the OPML" aria-label="Re-read the OPML">⟳</button>
|
||||
<button class="btn ico" data-a="read" title="Mark all read" aria-label="Mark all read">✓✓</button>
|
||||
<button class="btn ico" data-a="settings" title="Settings" aria-label="Settings">⚙</button>
|
||||
<button class="btn ico danger last" data-a="rm" title="Unsubscribe" aria-label="Unsubscribe">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1442,8 +1510,8 @@ async function prefsModal(){
|
||||
<div class="field"><label>Users</label>
|
||||
<div class="inline"><button class="btn" id="gusers">Manage users…</button></div>
|
||||
<span class="hint">Add and remove the people who can sign in, and choose who is an admin.</span></div>
|
||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn primary" id="gsave">Save</button></div>`);
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">✕</button>
|
||||
<button class="btn ico primary" id="gsave" title="Save" aria-label="Save">✓</button></div>`);
|
||||
$('#gopml').onclick=opmlModal;
|
||||
$('#gusers').onclick=usersModal;
|
||||
$('#gsave').onclick=async()=>{
|
||||
@@ -1539,8 +1607,8 @@ function settingsModal(f){
|
||||
<input type="text" id="sfolder" value="${esc(f.folder||'')}" placeholder="${esc(f.title||f.id)}">
|
||||
<span class="hint">Where the files land. There is one copy however many people
|
||||
subscribe, so this is the same for everyone.</span></div>`:''}
|
||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn primary" id="ssave">Save</button></div>`);
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">✕</button>
|
||||
<button class="btn ico primary" id="ssave" title="Save" aria-label="Save">✓</button></div>`);
|
||||
$('#scopy').onclick=()=>copyText($('#surl').value,$('#scopy'));
|
||||
$('#ssave').onclick=async()=>{
|
||||
const max=$('#smax').value;
|
||||
@@ -1566,8 +1634,8 @@ function downloadLatestModal(f){
|
||||
<div class="field"><label>How many of the newest undownloaded items?</label>
|
||||
<input type="number" id="dcount" min="1" max="100" value="5">
|
||||
<span class="hint">Queued immediately, ignoring the per-scan limit.</span></div>
|
||||
<div class="cardacts"><button class="btn" onclick="closeModal()">Cancel</button>
|
||||
<button class="btn primary" id="dgo">Download</button></div>`);
|
||||
<div class="cardacts"><button class="btn ico" onclick="closeModal()" title="Cancel" aria-label="Cancel">✕</button>
|
||||
<button class="btn ico primary" id="dgo" title="Download" aria-label="Download">⤓</button></div>`);
|
||||
$('#dgo').onclick=async()=>{
|
||||
const n=Number($('#dcount').value)||5;
|
||||
try{
|
||||
@@ -1640,11 +1708,18 @@ on('#logs','onclick',logsModal);
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
$('#scrim').onclick=()=>nav(false);
|
||||
$('#theme').onclick=()=>{
|
||||
const cur=document.documentElement.dataset.theme==='light'?'dark':'light';
|
||||
document.documentElement.dataset.theme=cur; localStorage.setItem('ipx.theme',cur);
|
||||
};
|
||||
if(localStorage.getItem('ipx.theme')) document.documentElement.dataset.theme=localStorage.getItem('ipx.theme');
|
||||
// Dark, light, and the 2004 Mac app. The button steps through them; the choice is remembered.
|
||||
const THEMES={dark:'Dark',light:'Light',classic:'Classic, the 2004 Mac app'};
|
||||
const THEME_ORDER=Object.keys(THEMES);
|
||||
const nextTheme=t=>THEME_ORDER[(THEME_ORDER.indexOf(t)+1)%THEME_ORDER.length];
|
||||
function setTheme(t){
|
||||
if(!THEMES[t]) t='dark';
|
||||
document.documentElement.dataset.theme=t;
|
||||
$('#theme').title=`Theme: ${THEMES[t]}. Click for ${THEMES[nextTheme(t)]}`;
|
||||
try{ localStorage.setItem('ipx.theme',t); }catch{}
|
||||
}
|
||||
$('#theme').onclick=()=>setTheme(nextTheme(document.documentElement.dataset.theme));
|
||||
try{ setTheme(localStorage.getItem('ipx.theme')); }catch{ setTheme('dark'); }
|
||||
|
||||
/* ---------------- live events ---------------- */
|
||||
let sse;
|
||||
|
||||
Reference in New Issue
Block a user