Initial import
244
README.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# MakeArmy Static Dash
|
||||
|
||||
Ultra-fast, no-framework homepage for curating links/tools with a **strict 3-column grid**, **config-driven theming**, and **crisp icon tiles**. Works on any static host.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# put these files in a folder
|
||||
index.html
|
||||
styles.css
|
||||
app.js
|
||||
config.json
|
||||
images/ma-icon.png # header logo
|
||||
images/favicon.png|ico # favicon(s)
|
||||
icons/*.svg|png # item icons
|
||||
|
||||
# serve locally (choose one)
|
||||
python3 -m http.server 8080
|
||||
# or
|
||||
npx live-server --port=8080
|
||||
|
||||
Open http://localhost:8080
|
||||
|
||||
Press / to focus Search. Use Tab to keyboard-navigate tiles (visible focus rings).
|
||||
|
||||
All content/layout is driven by config.json.
|
||||
|
||||
File structure
|
||||
|
||||
/
|
||||
├─ index.html # markup shell; content is injected at runtime
|
||||
├─ styles.css # styles (theme via CSS variables)
|
||||
├─ app.js # reads config.json, builds UI, handles grid logic
|
||||
├─ config.json # your data, colors, layout, icons
|
||||
├─ images/
|
||||
│ ├─ ma-icon.png
|
||||
│ └─ favicon.png (or .ico)
|
||||
└─ icons/
|
||||
├─ db-f.png
|
||||
├─ privatebin.svg
|
||||
└─ ... (all tile icons)
|
||||
|
||||
Config guide (config.json)
|
||||
|
||||
JSON must be valid: double-quoted keys/strings, no comments, no trailing commas.
|
||||
|
||||
Top level
|
||||
Key Type Description Default
|
||||
title string Big monospace header and <title> "Home"
|
||||
subtitle string Small italic tagline under the title ""
|
||||
header
|
||||
Key Type Description Default
|
||||
logo string (URL/path) Header square logo "/images/ma-icon.png"
|
||||
background color Full-bleed header color + <meta name="theme-color"> "#101010"
|
||||
searchPlaceholder string Search input placeholder "Start typing to filter"
|
||||
showSearch boolean Hide/show search true
|
||||
icons
|
||||
|
||||
Centralizes icon handling; item icons use filenames only and are prefixed by basePath.
|
||||
Key Type Description Default
|
||||
basePath string Prefix for all item icons (e.g., "/icons/") "" (none)
|
||||
fallbackIcon string If an icon 404s, swap to this file; if empty, a built-in SVG fallback is used ""
|
||||
defaultScale number Per-item default icon_scale multiplier 1
|
||||
boxPercent string Icon box size inside tile (e.g., "85%") "85%"
|
||||
preloadTopN number Preload up to N icons for faster first paint 16
|
||||
faviconPng string PNG favicon "/images/favicon.png"
|
||||
faviconIco string ICO favicon "/images/favicon.ico"
|
||||
ogImage string Social preview image header.logo
|
||||
typography
|
||||
Key Type Description Default
|
||||
sectionTitlePx number Section header font size (monospace) 22
|
||||
itemLabelPx number Tile label font size (bold sans) 12
|
||||
theme
|
||||
|
||||
Base greys, pastel accents, and hover glow tuning.
|
||||
Key Type Description Default
|
||||
background color Page background #202020
|
||||
panel color Section surface #121212
|
||||
tile color Tile surface #202020
|
||||
ink color Primary text #f2f2f2
|
||||
inkDim color Muted text #a9a9a9
|
||||
|
||||
theme.accents (used when a section’s accent is 1..4):
|
||||
|
||||
blue: "#3a85ff", green: "#1fc66e", red: "#ff3f5d", yellow: "#ffdb33"
|
||||
|
||||
theme.glow (tile hover underglow):
|
||||
|
||||
corePx (default 3) – thin inner highlight radius
|
||||
|
||||
blurPx (default 7) – small outer halo (spread)
|
||||
|
||||
coreAlpha (default 0.95) – inner brightness (0–1)
|
||||
|
||||
outerAlpha (default 0.64) – outer brightness (0–1)
|
||||
|
||||
layout
|
||||
|
||||
Strict, compact 3-across layout using a 6-track grid under the hood.
|
||||
Key Type Description Default
|
||||
pageMax number Content wrap width (header is full-bleed) 2400
|
||||
gapSections number Space between sections (px) 14
|
||||
gapItems number Space between tiles (px) 10
|
||||
gridCols number Internal grid tracks per section 6
|
||||
tileColspan number Default tile span 2
|
||||
sectionMin number Min section width (px) 300
|
||||
sectionBasis number Preferred section width (px) 320
|
||||
sectionMax number Max section width (px) 340
|
||||
tileHeight number Fixed tile row height (px) 62
|
||||
|
||||
Last-row behavior
|
||||
|
||||
1 leftover → tile spans all tracks (full width)
|
||||
|
||||
2 leftovers → each spans half width
|
||||
|
||||
3 or multiples of 3 → normal rows
|
||||
|
||||
a11y
|
||||
|
||||
reducedMotion (bool) – advisory; UI also respects OS prefs via prefers-reduced-motion.
|
||||
|
||||
focusOutline (bool) – advisory; visible focus rings are enabled.
|
||||
|
||||
sections
|
||||
|
||||
Array of section cards with a title, accent, and items.
|
||||
|
||||
Section fields
|
||||
Key Type Description
|
||||
title string Text in the colored bar
|
||||
accent number | string 1..4 uses theme.accents; hex string (e.g., "#8a6cff") for custom
|
||||
|
||||
Item fields
|
||||
Key Type Description
|
||||
label string Tile title (white, not underlined)
|
||||
href string Destination URL
|
||||
note string Optional subtitle (currently hidden in CSS; enable if desired)
|
||||
icon string Filename (e.g., "db-f.png") – combined with icons.basePath
|
||||
icon_scale number Per-icon scale to offset canvas padding (e.g., 1.15)
|
||||
icon_class string Extra CSS class for targeting
|
||||
icon_svg string Inline raw SVG string (advanced)
|
||||
target string _self or _blank
|
||||
check string URL to ping with <img> for “up/down” dot (should return any image; 4.5s timeout)
|
||||
Minimal example
|
||||
|
||||
{
|
||||
"title": "MakeArmy",
|
||||
"header": { "logo": "/images/ma-icon.png", "background": "#101010", "showSearch": true },
|
||||
"icons": { "basePath": "/icons/", "preloadTopN": 16 },
|
||||
"theme": { "background": "#202020", "panel": "#121212", "tile": "#202020" },
|
||||
"layout": { "pageMax": 2400, "gridCols": 6, "tileColspan": 2, "tileHeight": 62 },
|
||||
"sections": [
|
||||
{
|
||||
"title": "Utilities",
|
||||
"accent": 1,
|
||||
"items": [
|
||||
{ "label": "PrivateBin", "href": "https://paste.example.com", "icon": "privatebin.svg", "target": "_blank" },
|
||||
{ "label": "Images", "href": "https://img.example.com", "icon": "picsur.png", "target": "_blank" },
|
||||
{ "label": "Docs", "href": "https://docs.example.com", "icon": "docs.svg" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Theming quickies
|
||||
|
||||
Pastels: nudge theme.accents.* lighter/desaturated.
|
||||
|
||||
Glow: adjust theme.glow.blurPx (spread) and coreAlpha/outerAlpha (intensity).
|
||||
|
||||
Density: tweak layout.tileHeight, gapItems, gapSections.
|
||||
|
||||
Text: bump typography.itemLabelPx or sectionTitlePx.
|
||||
|
||||
Performance tips
|
||||
|
||||
Prefer SVG icons; for PNG, export at 2× with transparent background.
|
||||
|
||||
Keep icons.preloadTopN around 8–24.
|
||||
|
||||
Cache /icons/ aggressively: Cache-Control: public, max-age=31536000, immutable.
|
||||
|
||||
Keep app.js as defer (already set in index.html).
|
||||
|
||||
Deploying
|
||||
|
||||
Any static host: NGINX, Caddy, Apache, GitHub Pages, Cloudflare Pages, Netlify, S3 + CloudFront.
|
||||
|
||||
Headers to consider
|
||||
|
||||
Correct Content-Type for SVG: image/svg+xml.
|
||||
|
||||
Cache icons long-term; keep config.json lightly cached while iterating.
|
||||
|
||||
Keyboard & A11y
|
||||
|
||||
/ focuses search.
|
||||
|
||||
Tab to navigate; tiles show visible outlines in section accent color.
|
||||
|
||||
Reduced motion respected via prefers-reduced-motion.
|
||||
|
||||
Troubleshooting
|
||||
|
||||
“Failed to load config.json: JSON.parse …”
|
||||
|
||||
JSON is strict: no comments, no trailing commas, double-quoted keys/strings only.
|
||||
|
||||
Icons missing
|
||||
|
||||
Confirm icons.basePath (e.g., "/icons/").
|
||||
|
||||
Hard reload (Ctrl/Cmd-Shift-R).
|
||||
|
||||
Try opening https://your.site/icons/foo.png directly.
|
||||
|
||||
Set icons.fallbackIcon (e.g., "fallback.svg").
|
||||
|
||||
Rows/columns odd or last row wraps
|
||||
|
||||
Keep layout.gridCols: 6, tileColspan: 2.
|
||||
|
||||
Ensure layout numbers are plain numbers (app adds px).
|
||||
|
||||
Tiles too tall/short
|
||||
|
||||
Adjust layout.tileHeight and/or typography.itemLabelPx.
|
||||
|
||||
Status dot always ‘down’
|
||||
|
||||
check must return an image (some endpoints block image hotlinking). Use a dedicated “status pixel”.
|
||||
|
||||
Contributing / Maintaining
|
||||
|
||||
Add/remove items by editing sections[*].items in config.json. Order matters.
|
||||
|
||||
Move objects inside the sections array to change section order.
|
||||
|
||||
Set section accent to 1..4 (uses theme palette) or a custom hex (e.g., "#8a6cff").
|
||||
|
||||
To centralize icons, keep filenames in items and set "icons.basePath": "/icons/".
|
||||
373
app.js
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
// app.js — config-driven dash with strong icon fallback, basePath,
|
||||
// robust grid spanning, and full set of knobs
|
||||
|
||||
const elApp = document.getElementById('app');
|
||||
const elSearch = document.getElementById('search');
|
||||
|
||||
let DEFAULT_ICON_SCALE = 1;
|
||||
let ICON_BOX_PCT = '85%';
|
||||
let FALLBACK_ICON = '';
|
||||
let ICON_BASE = ''; // NEW: optional basePath for icons
|
||||
let BUILTIN_FALLBACK = // SVG data-URI fallback if everything fails
|
||||
'data:image/svg+xml;utf8,' +
|
||||
encodeURIComponent(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="8" fill="#2a2a2a"/>
|
||||
<path d="M10 46l10-12 7 8 9-12 18 24H10z" fill="#bdbdbd"/>
|
||||
<circle cx="24" cy="22" r="4" fill="#e0e0e0"/>
|
||||
</svg>
|
||||
`);
|
||||
|
||||
/* Focus search on "/" */
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === '/' && document.activeElement !== elSearch) {
|
||||
e.preventDefault();
|
||||
elSearch?.focus();
|
||||
elSearch?.select();
|
||||
}
|
||||
});
|
||||
|
||||
/* Load config */
|
||||
fetch('config.json', { cache: 'no-store' })
|
||||
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); })
|
||||
.then(cfg => {
|
||||
// knobs
|
||||
DEFAULT_ICON_SCALE = cfg?.icons?.defaultScale ?? 1;
|
||||
ICON_BOX_PCT = cfg?.icons?.boxPercent ?? '85%';
|
||||
FALLBACK_ICON = cfg?.icons?.fallbackIcon || '';
|
||||
ICON_BASE = cfg?.icons?.basePath || '';
|
||||
|
||||
applyHeader(cfg);
|
||||
applyTheme(cfg);
|
||||
applyTypography(cfg);
|
||||
applyLayout(cfg);
|
||||
applyIcons(cfg);
|
||||
preloadIcons(cfg);
|
||||
|
||||
document.title = (cfg.title || 'Home') + ' • Home';
|
||||
|
||||
(cfg.sections || []).forEach((sec, idx) => {
|
||||
const node = renderSection(cfg, sec, idx);
|
||||
elApp.appendChild(node);
|
||||
});
|
||||
|
||||
attachIconFallback(); // after DOM is built
|
||||
attachSearch();
|
||||
// Re-apply spanning on resize (Firefox sometimes needs this)
|
||||
window.addEventListener('resize', () => {
|
||||
document.querySelectorAll('.grid').forEach(g => applyStretching(g));
|
||||
});
|
||||
|
||||
(window.requestIdleCallback ? requestIdleCallback(checkAll) : setTimeout(checkAll, 200));
|
||||
})
|
||||
.catch(err => {
|
||||
elApp.innerHTML = `<p style="color:#ff6b6b">Failed to load config.json: ${escapeHtml(err.message)}</p>`;
|
||||
});
|
||||
|
||||
/* --------------------- Header / Meta / Favicons --------------------- */
|
||||
function applyHeader(cfg){
|
||||
const h = cfg.header || {};
|
||||
const titleEl = document.querySelector('.titles h1');
|
||||
const subEl = document.querySelector('.titles p');
|
||||
const logoEl = document.querySelector('.logo img');
|
||||
const searchWrap = document.querySelector('.search');
|
||||
const searchInput = document.getElementById('search');
|
||||
|
||||
if (titleEl) titleEl.textContent = cfg.title || 'Home';
|
||||
if (subEl) subEl.textContent = cfg.subtitle || '';
|
||||
if (logoEl && h.logo) logoEl.src = h.logo;
|
||||
|
||||
document.documentElement.style.setProperty('--topbar-bg', h.background || '#101010');
|
||||
|
||||
if (searchWrap) searchWrap.style.display = h.showSearch === false ? 'none' : '';
|
||||
if (searchInput && h.searchPlaceholder) searchInput.placeholder = h.searchPlaceholder;
|
||||
|
||||
// theme-color meta
|
||||
setOrCreateMeta('meta-theme-color', 'theme-color', h.background || '#101010');
|
||||
|
||||
// Open Graph (title/image)
|
||||
setOrCreateMeta('og-title', 'og:title', cfg.title || 'MakeArmy', true);
|
||||
setOrCreateMeta('og-site_name', 'og:site_name', cfg.title || 'MakeArmy', true);
|
||||
if (cfg.icons?.ogImage) setOrCreateMeta('og-image', 'og:image', cfg.icons.ogImage, true);
|
||||
}
|
||||
|
||||
function applyIcons(cfg){
|
||||
const ico = cfg.icons || {};
|
||||
if (ico.faviconPng) setOrCreateFavicon('favicon-png', 'image/png', ico.faviconPng);
|
||||
if (ico.faviconIco) setOrCreateFavicon('favicon-ico', '', ico.faviconIco);
|
||||
}
|
||||
|
||||
function setOrCreateFavicon(id, type, href){
|
||||
let link = document.getElementById(id);
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.id = id; link.rel = 'icon';
|
||||
if (type) link.type = type;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = href;
|
||||
}
|
||||
|
||||
function setOrCreateMeta(id, name, content, isOg=false){
|
||||
let meta = document.getElementById(id);
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.id = id;
|
||||
if (isOg) meta.setAttribute('property', name); else meta.setAttribute('name', name);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.setAttribute('content', content);
|
||||
}
|
||||
|
||||
/* --------------------- Theme / Layout / Typography --------------------- */
|
||||
function applyTheme(cfg){
|
||||
const t = cfg.theme || {};
|
||||
setVar('--bg0', t.background || '#202020');
|
||||
setVar('--panel', t.panel || '#121212');
|
||||
setVar('--tile', t.tile || '#202020');
|
||||
setVar('--ink', t.ink || '#f2f2f2');
|
||||
setVar('--ink-dim',t.inkDim || '#a9a9a9');
|
||||
|
||||
const acc = t.accents || {};
|
||||
const A = {
|
||||
blue: acc.blue || '#3a85ff',
|
||||
green: acc.green || '#1fc66e',
|
||||
red: acc.red || '#ff3f5d',
|
||||
yellow: acc.yellow || '#ffdb33'
|
||||
};
|
||||
setVar('--blue', A.blue);
|
||||
setVar('--green', A.green);
|
||||
setVar('--red', A.red);
|
||||
setVar('--yellow', A.yellow);
|
||||
|
||||
// RGB components for glow rgba()
|
||||
const [br,bg,bb] = hexToRgb(A.blue);
|
||||
const [gr,gg,gb] = hexToRgb(A.green);
|
||||
const [rr,rg,rb] = hexToRgb(A.red);
|
||||
const [yr,yg,yb] = hexToRgb(A.yellow);
|
||||
setVar('--blue-rgb', `${br}, ${bg}, ${bb}`);
|
||||
setVar('--green-rgb', `${gr}, ${gg}, ${gb}`);
|
||||
setVar('--red-rgb', `${rr}, ${rg}, ${rb}`);
|
||||
setVar('--yellow-rgb', `${yr}, ${yg}, ${yb}`);
|
||||
|
||||
const g = t.glow || {};
|
||||
setVar('--glow-core', `${num(g.corePx,3)}px`);
|
||||
setVar('--glow-blur', `${num(g.blurPx,7)}px`);
|
||||
setVar('--glow-core-a', num(g.coreAlpha,0.95));
|
||||
setVar('--glow-outer-a', num(g.outerAlpha,0.64));
|
||||
}
|
||||
|
||||
function applyTypography(cfg){
|
||||
const T = cfg.typography || {};
|
||||
setVar('--section-title-px', num(T.sectionTitlePx, 22));
|
||||
setVar('--item-label-px', num(T.itemLabelPx, 12));
|
||||
}
|
||||
|
||||
// helper for px values
|
||||
function px(v, d){ return `${num(v, d)}px`; }
|
||||
|
||||
function applyLayout(cfg){
|
||||
const L = cfg.layout || {};
|
||||
// keep unitless; CSS uses calc(var(--page-max) * 1px)
|
||||
setVar('--page-max', num(L.pageMax, 2400));
|
||||
|
||||
// spacing + dimensions in px
|
||||
setVar('--gap-sec', px(L.gapSections, 14));
|
||||
setVar('--gap-item', px(L.gapItems, 10));
|
||||
setVar('--section-min', px(L.sectionMin, 300));
|
||||
setVar('--section-basis', px(L.sectionBasis, 320));
|
||||
setVar('--section-max', px(L.sectionMax, 340));
|
||||
setVar('--tile-h', px(L.tileHeight, 62));
|
||||
|
||||
// grid knobs (integers)
|
||||
setVar('--grid-cols', String(num(L.gridCols, 6)));
|
||||
setVar('--tile-colspan', String(num(L.tileColspan, 2)));
|
||||
|
||||
// icon box percent
|
||||
setVar('--icon-box-pct', ICON_BOX_PCT);
|
||||
}
|
||||
|
||||
/* --------------------- Render Sections / Items --------------------- */
|
||||
function renderSection(cfg, section, idx){
|
||||
const sec = document.createElement('section');
|
||||
sec.className = 'section';
|
||||
|
||||
// Accent: number 1..4 uses global vars; hex sets custom per-section vars
|
||||
let accentIndex = ((idx % 4) + 1);
|
||||
if (typeof section.accent === 'number') {
|
||||
accentIndex = Math.max(1, Math.min(4, section.accent));
|
||||
sec.dataset.accent = String(accentIndex);
|
||||
} else if (typeof section.accent === 'string' && section.accent.trim().startsWith('#')) {
|
||||
const hex = section.accent.trim();
|
||||
sec.classList.add('custom-accent');
|
||||
sec.style.setProperty('--accent', hex);
|
||||
const [r,g,b] = hexToRgb(hex);
|
||||
sec.style.setProperty('--accent-rgb', `${r}, ${g}, ${b}`);
|
||||
} else {
|
||||
sec.dataset.accent = String(accentIndex);
|
||||
}
|
||||
|
||||
sec.innerHTML = `
|
||||
<div class="head">
|
||||
<span>▸ ${escapeHtml(section.title || 'Section')}</span>
|
||||
<span class="gear" aria-hidden="true">⋮</span>
|
||||
</div>
|
||||
<div class="grid"></div>
|
||||
`;
|
||||
const grid = sec.querySelector('.grid');
|
||||
|
||||
(section.items || []).forEach(item => {
|
||||
const a = document.createElement('a');
|
||||
a.className = 'tile';
|
||||
a.href = item.href || '#';
|
||||
a.rel = 'noopener noreferrer';
|
||||
a.target = item.target || '_self';
|
||||
a.dataset.search = (item.label + ' ' + (item.note || '') + ' ' + (section.title || '')).toLowerCase();
|
||||
a.setAttribute('aria-label', item.label || 'Link');
|
||||
|
||||
a.innerHTML = `
|
||||
<div class="icon">${renderIcon(item)}</div>
|
||||
<div>
|
||||
<div class="label">${escapeHtml(item.label || 'Link')}</div>
|
||||
${item.note ? `<div class="sub">${escapeHtml(item.note)}</div>` : ''}
|
||||
</div>
|
||||
<div class="dot${item.check ? '' : ' ok'}" title="${item.check ? 'checking…' : 'static'}"></div>
|
||||
`;
|
||||
|
||||
if (item.check) a.dataset.check = item.check; // status ping URL
|
||||
grid.appendChild(a);
|
||||
});
|
||||
|
||||
// Apply last-row stretching now that tiles exist
|
||||
applyStretching(grid);
|
||||
|
||||
return sec;
|
||||
}
|
||||
|
||||
function renderIcon(item){
|
||||
const ic = item.icon;
|
||||
const scale = (typeof item.icon_scale === 'number' && isFinite(item.icon_scale))
|
||||
? item.icon_scale
|
||||
: DEFAULT_ICON_SCALE;
|
||||
|
||||
if (typeof ic === 'string' && /\.(svg|png|webp|jpe?g|gif)$/i.test(ic)) {
|
||||
const cls = item.icon_class ? ` class="${escapeAttr(item.icon_class)}"` : '';
|
||||
const src = resolveIconSrc(ic);
|
||||
return `<img src="${escapeAttr(src)}"${cls} alt="" loading="lazy" decoding="async" style="--icon-scale:${scale}">`;
|
||||
}
|
||||
if (item.icon_svg) return item.icon_svg;
|
||||
return '🔗';
|
||||
}
|
||||
|
||||
function resolveIconSrc(path){
|
||||
// If absolute URL or root path, use as-is. Else prefix with ICON_BASE (if set).
|
||||
if (/^https?:\/\//i.test(path) || path.startsWith('/')) return path;
|
||||
if (ICON_BASE) return ICON_BASE.replace(/\/+$/,'/') + path.replace(/^\/+/, '');
|
||||
return path;
|
||||
}
|
||||
|
||||
/* --------------------- Search + Filter --------------------- */
|
||||
function attachSearch(){
|
||||
const tiles = Array.from(document.querySelectorAll('.tile'));
|
||||
const sections = Array.from(document.querySelectorAll('.section'));
|
||||
const run = () => {
|
||||
const q = (document.getElementById('search')?.value || '').trim().toLowerCase();
|
||||
tiles.forEach(t => t.classList.toggle('hidden', q && !t.dataset.search.includes(q)));
|
||||
sections.forEach(s => {
|
||||
const grid = s.querySelector('.grid');
|
||||
const anyVisible = grid && Array.from(grid.querySelectorAll('.tile')).some(t => !t.classList.contains('hidden'));
|
||||
s.classList.toggle('hidden', !anyVisible);
|
||||
if (grid) applyStretching(grid);
|
||||
});
|
||||
};
|
||||
document.getElementById('search')?.addEventListener('input', run);
|
||||
run();
|
||||
}
|
||||
|
||||
/* --------------------- Status "up" checks via <img> --------------------- */
|
||||
function checkAll(){
|
||||
document.querySelectorAll('.tile[data-check]').forEach(tile => {
|
||||
const dot = tile.querySelector('.dot');
|
||||
const url = tile.dataset.check + (tile.dataset.check.includes('?') ? '&' : '?') + 't=' + Date.now();
|
||||
const img = new Image();
|
||||
const fail = () => { dot.classList.add('down'); dot.title = 'down'; };
|
||||
const ok = () => { dot.classList.remove('down'); dot.title = 'up'; };
|
||||
const timeout = setTimeout(fail, 4500);
|
||||
img.onload = () => { clearTimeout(timeout); ok(); };
|
||||
img.onerror = () => { clearTimeout(timeout); fail(); };
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------- Last-row stretching (robust) --------------------- */
|
||||
function applyStretching(grid){
|
||||
const tiles = Array.from(grid.querySelectorAll('.tile'))
|
||||
.filter(t => !t.classList.contains('hidden'));
|
||||
|
||||
// Reset any previous manual spans
|
||||
tiles.forEach(t => { t.style.gridColumn = ''; });
|
||||
|
||||
const gridCols = readCssInt(grid, '--grid-cols', 6);
|
||||
const tileSpan = readCssInt(grid, '--tile-colspan', 2);
|
||||
const perRow = Math.max(1, Math.floor(gridCols / tileSpan));
|
||||
const remainder = tiles.length % perRow;
|
||||
|
||||
if (remainder === 1) {
|
||||
// 1 leftover → full width
|
||||
tiles[tiles.length - 1].style.gridColumn = `span ${gridCols}`;
|
||||
} else if (remainder === 2) {
|
||||
// 2 leftovers → 50/50
|
||||
const half = Math.max(tileSpan, Math.floor(gridCols / 2));
|
||||
tiles[tiles.length - 2].style.gridColumn = `span ${half}`;
|
||||
tiles[tiles.length - 1].style.gridColumn = `span ${half}`;
|
||||
}
|
||||
}
|
||||
|
||||
function readCssInt(el, varName, fallback){
|
||||
let v = parseInt(getComputedStyle(el).getPropertyValue(varName), 10);
|
||||
if (!v) v = parseInt(getComputedStyle(document.documentElement).getPropertyValue(varName), 10);
|
||||
return Number.isFinite(v) && v > 0 ? v : fallback;
|
||||
}
|
||||
|
||||
/* --------------------- Preload top icons (perf) --------------------- */
|
||||
function preloadIcons(cfg){
|
||||
const limit = Math.max(0, Math.min(64, cfg?.icons?.preloadTopN ?? 16));
|
||||
if (!limit) return;
|
||||
const seen = new Set();
|
||||
const paths = [];
|
||||
(cfg.sections || []).forEach(s => (s.items || []).forEach(i => {
|
||||
if (typeof i.icon === 'string' && /\.(svg|png|webp|jpe?g|gif)$/i.test(i.icon)) {
|
||||
const src = resolveIconSrc(i.icon);
|
||||
if (!seen.has(src)) { seen.add(src); paths.push(src); }
|
||||
}
|
||||
}));
|
||||
paths.slice(0, limit).forEach(src => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload'; link.as = 'image'; link.href = src;
|
||||
document.head.appendChild(link);
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------- Icon fallback --------------------- */
|
||||
function attachIconFallback(){
|
||||
document.querySelectorAll('.icon img').forEach(img => {
|
||||
img.addEventListener('error', () => {
|
||||
if (img.dataset.fallbackApplied) return;
|
||||
img.dataset.fallbackApplied = '1';
|
||||
img.src = FALLBACK_ICON || BUILTIN_FALLBACK;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------- Utils --------------------- */
|
||||
function hexToRgb(hex){
|
||||
let h = hex.replace('#','').trim();
|
||||
if (h.length === 3) h = h.split('').map(c => c + c).join('');
|
||||
const numHex = parseInt(h,16);
|
||||
const r = (numHex >> 16) & 255, g = (numHex >> 8) & 255, b = numHex & 255;
|
||||
return [r,g,b];
|
||||
}
|
||||
function setVar(name, val){ document.documentElement.style.setProperty(name, String(val)); }
|
||||
function num(v, d){ return (typeof v === 'number' && isFinite(v)) ? v : d; }
|
||||
function escapeHtml(s){ return String(s).replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); }
|
||||
function escapeAttr(s){ return String(s).replace(/"/g, '"'); }
|
||||
288
config.json
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
{
|
||||
"title": "MakeArmy",
|
||||
"subtitle": "Welcome to the Maker Dash",
|
||||
|
||||
"header": {
|
||||
"logo": "/images/ma-icon.png",
|
||||
"background": "#101010",
|
||||
"searchPlaceholder": "Start typing to filter",
|
||||
"showSearch": true
|
||||
},
|
||||
|
||||
"icons": {
|
||||
"basePath": "/icons/",
|
||||
"fallbackIcon": "fallback.svg",
|
||||
"defaultScale": 1,
|
||||
"boxPercent": "85%",
|
||||
"preloadTopN": 16,
|
||||
"faviconPng": "/images/favicon.png",
|
||||
"faviconIco": "/images/favicon.ico",
|
||||
"ogImage": "/images/ma-icon.png"
|
||||
},
|
||||
|
||||
"typography": {
|
||||
"sectionTitlePx": 22,
|
||||
"itemLabelPx": 12
|
||||
},
|
||||
|
||||
"theme": {
|
||||
"background": "#202020",
|
||||
"panel": "#121212",
|
||||
"tile": "#202020",
|
||||
"ink": "#f2f2f2",
|
||||
"inkDim": "#a9a9a9",
|
||||
"accents": {
|
||||
"blue": "#3a85ff",
|
||||
"green": "#1fc66e",
|
||||
"red": "#ff3f5d",
|
||||
"yellow": "#ffdb33"
|
||||
},
|
||||
"glow": {
|
||||
"corePx": 3,
|
||||
"blurPx": 7,
|
||||
"coreAlpha": 0.95,
|
||||
"outerAlpha": 0.64
|
||||
}
|
||||
},
|
||||
|
||||
"layout": {
|
||||
"pageMax": 2400,
|
||||
"gapSections": 14,
|
||||
"gapItems": 10,
|
||||
"gridCols": 6,
|
||||
"tileColspan": 2,
|
||||
"sectionMin": 300,
|
||||
"sectionBasis": 320,
|
||||
"sectionMax": 340,
|
||||
"tileHeight": 62
|
||||
},
|
||||
|
||||
"a11y": {
|
||||
"reducedMotion": true,
|
||||
"focusOutline": true
|
||||
},
|
||||
|
||||
"sections": [
|
||||
{
|
||||
"title": "Laser Everything",
|
||||
"accent": "#8a6cff",
|
||||
"items": [
|
||||
{
|
||||
"label": "Fiber Settings",
|
||||
"note": "community laser settings for fiber",
|
||||
"icon": "db-f.png",
|
||||
"href": "https://makearmy.io/fiber-settings",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/fiber-settings"
|
||||
},
|
||||
{
|
||||
"label": "UV Settings",
|
||||
"note": "community laser settings for uv",
|
||||
"icon": "db-u.png",
|
||||
"href": "https://makearmy.io/uv-settings",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/uv-settings"
|
||||
},
|
||||
{
|
||||
"label": "CO2g Settings",
|
||||
"note": "community laser settings for co2 galvo",
|
||||
"icon": "db-c.png",
|
||||
"href": "https://makearmy.io/co2-galvo-settings",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/co2-galvo-settings"
|
||||
},
|
||||
{
|
||||
"label": "CO2G Settings",
|
||||
"note": "community laser settings for co2 gantry",
|
||||
"icon": "db-cg.png",
|
||||
"href": "https://makearmy.io/co2-gantry-settings",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/co2-gantry-settings"
|
||||
},
|
||||
{
|
||||
"label": "Material Safety",
|
||||
"note": "material safety lookup table",
|
||||
"icon": "db-m.png",
|
||||
"href": "https://makearmy.io/materials",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/materials"
|
||||
},
|
||||
{
|
||||
"label": "Coating Safety",
|
||||
"note": "material coating safety lookup table",
|
||||
"icon": "db-mc.png",
|
||||
"href": "https://makearmy.io/materials-coatings",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/materials-coatings"
|
||||
},
|
||||
{
|
||||
"label": "Project DB",
|
||||
"note": "community submitted project files",
|
||||
"icon": "db-p.png",
|
||||
"href": "https://makearmy.io/projects",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/projects"
|
||||
},
|
||||
{
|
||||
"label": "Laser Source DB",
|
||||
"note": "database of all known laser sources",
|
||||
"icon": "db-l.png",
|
||||
"href": "https://makearmy.io/lasers",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/lasers"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Communities",
|
||||
"accent": 2,
|
||||
"items": [
|
||||
{
|
||||
"label": "Misskey",
|
||||
"note": "the federated maker hub",
|
||||
"icon": "deck.png",
|
||||
"href": "https://deck.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://deck.makearmy.io"
|
||||
},
|
||||
{
|
||||
"label": "Mastodon",
|
||||
"note": "status update platform",
|
||||
"icon": "mastodon.png",
|
||||
"href": "https://mastodon.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://mastodon.makearmy.io"
|
||||
},
|
||||
{
|
||||
"label": "Lemmy",
|
||||
"note": "link aggregator",
|
||||
"icon": "lemmy.png",
|
||||
"href": "https://lemmy.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://lemmy.makearmy.io"
|
||||
},
|
||||
{
|
||||
"label": "Pixelfed",
|
||||
"note": "social image sharing platform",
|
||||
"icon": "pixelfed.png",
|
||||
"href": "https://pixels.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://pixels.makearmy.io"
|
||||
},
|
||||
{
|
||||
"label": "PeerTube",
|
||||
"note": "social video sharing platform",
|
||||
"icon": "peertube.png",
|
||||
"href": "https://watch.makearmy.io/",
|
||||
"target": "_blank",
|
||||
"check": "https://watch.makearmy.io/home"
|
||||
},
|
||||
{
|
||||
"label": "News",
|
||||
"note": "updates on the maker world from makearmy",
|
||||
"icon": "news.png",
|
||||
"href": "https://news.makearmy.io/read",
|
||||
"target": "_blank",
|
||||
"check": "https://news.makearmy.io/read"
|
||||
},
|
||||
{
|
||||
"label": "Podcasts",
|
||||
"note": "follow our podcasts! fediverse compatible!",
|
||||
"icon": "castopod.png",
|
||||
"href": "https://podcast.makearmy.io/",
|
||||
"target": "_blank",
|
||||
"check": "https://podcast.makearmy.io/@LaserSource"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Utilities",
|
||||
"accent": 1,
|
||||
"items": [
|
||||
{
|
||||
"label": "Picsur",
|
||||
"note": "Simple Image Host",
|
||||
"icon": "picsur.png",
|
||||
"href": "https://images.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://images.makearmy.io"
|
||||
},
|
||||
{
|
||||
"label": "PrivateBin",
|
||||
"note": "Your encrypted internet clipboard.",
|
||||
"icon": "privatebin.png",
|
||||
"href": "https://paste.makearmy.io/",
|
||||
"target": "_blank",
|
||||
"check": "https://paste.makearmy.io/"
|
||||
},
|
||||
{
|
||||
"label": "Laser Toolkit",
|
||||
"note": "convert laser settings, interval and more",
|
||||
"icon": "toolkit.png",
|
||||
"href": "https://makearmy.io/laser-toolkit",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/laser-toolkit"
|
||||
},
|
||||
{
|
||||
"label": "File Server",
|
||||
"note": "download from our file explorer",
|
||||
"icon": "fs.png",
|
||||
"href": "https://makearmy.io/files",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/files"
|
||||
},
|
||||
{
|
||||
"label": "Buying Guide",
|
||||
"note": "reviews and listings for relevant products",
|
||||
"icon": "bg.png",
|
||||
"href": "https://makearmy.io/buying-guide",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/buying-guide"
|
||||
},
|
||||
{
|
||||
"label": "BG Remover",
|
||||
"note": "advanced open source background remover featuring 10 AI models",
|
||||
"icon": "bgrm.png",
|
||||
"href": "https://makearmy.io/background-remover",
|
||||
"target": "_blank",
|
||||
"check": "https://makearmy.io/background-remover"
|
||||
},
|
||||
{
|
||||
"label": "Forgejo",
|
||||
"note": "git for our community members",
|
||||
"icon": "forgejo.png",
|
||||
"href": "https://forge.makearmy.io",
|
||||
"target": "_blank",
|
||||
"check": "https://forge.makearmy.io"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Support Us",
|
||||
"accent": 4,
|
||||
"items": [
|
||||
{
|
||||
"label": "The LMA",
|
||||
"note": "the laser master academy is the best way to show your support",
|
||||
"icon": "lma.png",
|
||||
"href": "https://www.mightynetworks.com/",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"label": "Patreon",
|
||||
"note": "patreon is a great way to support the work we're doing",
|
||||
"icon": "patreon.png",
|
||||
"href": "https://www.patreon.com/LaserEverything",
|
||||
"target": "_blank"
|
||||
},
|
||||
{
|
||||
"label": "Liberapay",
|
||||
"note": "more discreet ways to pay",
|
||||
"icon": "liberapay.png",
|
||||
"href": "https://liberapay.com/LaserEverything",
|
||||
"target": "_blank"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
icons/bg.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
icons/bgrm.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
icons/castopod.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
icons/db-c.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
icons/db-cg.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
icons/db-f.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
icons/db-l.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
icons/db-m.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
icons/db-mc.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
icons/db-p.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/db-u.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
icons/db.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
icons/deck.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/ergo.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
icons/forgejo.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
icons/fs.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
BIN
icons/icon3.png
Normal file
|
After Width: | Height: | Size: 6 KiB |
BIN
icons/icon4.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
BIN
icons/lemmy.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
icons/liberapay.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/lma.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
icons/lpclpi.png
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
BIN
icons/mastodon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
icons/matrix.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
icons/news.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
icons/nextcloud.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
icons/patreon.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
icons/peertube.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
icons/picsur.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
icons/pixelfed.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
icons/privatebin.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
icons/toolkit.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
images/favicon.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
images/ma-icon.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
36
index.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>MakeArmy • Home</title>
|
||||
|
||||
<!-- Favicons -->
|
||||
<link rel="icon" href="/images/favicon.png" type="image/png">
|
||||
<link rel="icon" href="/images/favicon.ico">
|
||||
|
||||
<link rel="preload" href="styles.css" as="style">
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="logo">
|
||||
<img src="/images/ma-icon.png" alt="MakeArmy logo">
|
||||
</div>
|
||||
<div class="titles">
|
||||
<h1>MakeArmy</h1>
|
||||
<p>Welcome to the Maker Dash</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search">
|
||||
<input id="search" type="search" placeholder="Start typing to filter" autocomplete="off" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="app" class="wrap" aria-live="polite"></main>
|
||||
|
||||
<script defer src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
234
styles.css
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
/* === Config-driven Ultra-compact 4-up • full-bleed header, crisp icons, tight bright glow === */
|
||||
|
||||
:root{
|
||||
/* fonts */
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, "Liberation Mono",
|
||||
"DejaVu Sans Mono", "Courier New", monospace;
|
||||
--sans: Inter, "Inter var", "InterVariable", "Public Sans",
|
||||
"Noto Sans", "Liberation Sans", ui-sans-serif, system-ui,
|
||||
-apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* greys + ink (JS can override) */
|
||||
--bg0:#202020; --panel:#121212; --tile:#202020;
|
||||
--ink:#f2f2f2; --ink-dim:#a9a9a9;
|
||||
|
||||
/* accents (JS can override) */
|
||||
--blue:#3a85ff; --blue-rgb:58,133,255;
|
||||
--green:#1fc66e; --green-rgb:31,198,110;
|
||||
--red:#ff3f5d; --red-rgb:255,63,93;
|
||||
--yellow:#ffdb33; --yellow-rgb:255,219,51;
|
||||
|
||||
/* glow intensity/shape (JS can override) */
|
||||
--glow-core: 3px;
|
||||
--glow-blur: 7px;
|
||||
--glow-core-a: .95;
|
||||
--glow-outer-a: .64;
|
||||
|
||||
/* layout (JS can override) */
|
||||
--page-max:2400;
|
||||
--gap-sec:14px; /* between sections */
|
||||
--gap-item:10px; /* between tiles */
|
||||
|
||||
/* NEW: grid controls */
|
||||
--grid-cols: 6; /* columns in section grid */
|
||||
--tile-colspan: 2; /* default span for a tile (→ 3 per row with 6 cols) */
|
||||
|
||||
--section-min:300px;
|
||||
--section-basis:320px;
|
||||
--section-max:340px;
|
||||
--tile-h:62px;
|
||||
|
||||
/* header bg (JS overrides via --topbar-bg) */
|
||||
--topbar-bg:#101010;
|
||||
|
||||
/* NEW: typography knobs */
|
||||
--section-title-px: 22;
|
||||
--item-label-px: 12;
|
||||
|
||||
/* NEW: icon box size */
|
||||
--icon-box-pct: 85%;
|
||||
|
||||
/* radii & shadows */
|
||||
--radius-sec:4px; --radius-tile:3px;
|
||||
--shadow-soft:0 2px 8px rgba(0,0,0,.30);
|
||||
}
|
||||
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0; color:var(--ink); background:var(--bg0);
|
||||
font:11.5px/1.35 var(--sans);
|
||||
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
||||
}
|
||||
|
||||
/* ================= HEADER (full-bleed) ================= */
|
||||
.topbar{
|
||||
background:var(--topbar-bg);
|
||||
width:100%;
|
||||
margin:0 0 16px;
|
||||
padding:16px 18px;
|
||||
display:flex; align-items:center; gap:18px;
|
||||
box-sizing:border-box;
|
||||
}
|
||||
.brand{display:flex; align-items:center; gap:14px}
|
||||
.logo{ width:66px; height:66px; border-radius:12px; overflow:hidden; display:grid; place-items:center; }
|
||||
.logo img{ width:100%; height:100%; object-fit:contain; image-rendering:auto; -ms-interpolation-mode:bicubic; }
|
||||
.titles h1{ margin:0; font-weight:800; font-family:var(--mono); font-size:36px; line-height:1; letter-spacing:.2px }
|
||||
.titles p{ margin:4px 0 0 0; color:#b8b8b8; font:italic 400 16px/1.25 var(--sans) }
|
||||
.search{margin-left:auto}
|
||||
.search input{
|
||||
width:360px; max-width:50vw;
|
||||
padding:10px 14px; border-radius:10px;
|
||||
background:#181818; color:var(--ink); border:none;
|
||||
}
|
||||
|
||||
/* ================= SECTIONS ================= */
|
||||
.wrap{
|
||||
/* FIX: use calc() so unitless --page-max is multiplied by px */
|
||||
max-width: min(95vw, calc(var(--page-max) * 1px));
|
||||
margin:0 auto 18px; padding:0 6px;
|
||||
display:flex; flex-wrap:wrap; gap:var(--gap-sec); align-items:flex-start;
|
||||
}
|
||||
.section{
|
||||
flex:1 1 var(--section-basis);
|
||||
min-width: var(--section-min);
|
||||
max-width: var(--section-max);
|
||||
background:var(--panel); border-radius:var(--radius-sec); overflow:hidden;
|
||||
}
|
||||
|
||||
/* Section headers (monospace, configurable size) */
|
||||
.section .head{
|
||||
display:flex; align-items:center; gap:8px; padding:8px 10px;
|
||||
font-weight:900; font-family:var(--mono); line-height:1;
|
||||
font-size: calc(var(--section-title-px) * 1px);
|
||||
letter-spacing:.08px; color:#0a0a0a;
|
||||
}
|
||||
.section .gear{margin-left:auto; opacity:.6; font-size:12px}
|
||||
|
||||
/* default accent by index */
|
||||
.section[data-accent="1"] .head{ background:var(--blue); }
|
||||
.section[data-accent="2"] .head{ background:var(--green); }
|
||||
.section[data-accent="3"] .head{ background:var(--red); }
|
||||
.section[data-accent="4"] .head{ background:var(--yellow); }
|
||||
|
||||
/* custom per-section accent */
|
||||
.section.custom-accent .head{ background:var(--accent); }
|
||||
|
||||
/* === STRICT GRID INSIDE EACH SECTION === */
|
||||
.grid{
|
||||
display:grid;
|
||||
gap:var(--gap-item);
|
||||
padding:var(--gap-item);
|
||||
background:var(--panel);
|
||||
|
||||
/* NEW: 6-track grid by default */
|
||||
grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr));
|
||||
grid-auto-rows: var(--tile-h);
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
|
||||
/* Default tile span (→ 3 per row with 6 cols) */
|
||||
.tile{ grid-column: span var(--tile-colspan); }
|
||||
|
||||
.tile{
|
||||
position:relative;
|
||||
background:var(--tile);
|
||||
border:none; border-radius:var(--radius-tile);
|
||||
box-shadow:var(--shadow-soft);
|
||||
transition: transform .06s ease, box-shadow .12s ease, background-color .12s ease;
|
||||
|
||||
display:grid;
|
||||
grid-template-areas:"title" "icon";
|
||||
grid-template-rows: 44% 56%;
|
||||
padding:5px;
|
||||
|
||||
color:var(--ink);
|
||||
text-decoration:none;
|
||||
}
|
||||
.tile:link, .tile:visited, .tile:hover, .tile:active{
|
||||
color:var(--ink); text-decoration:none;
|
||||
}
|
||||
.tile:hover{ transform:translateY(-1px); }
|
||||
|
||||
/* Hover underglow: tighter + brighter, config controlled */
|
||||
.section[data-accent="1"] .tile:hover{
|
||||
box-shadow:
|
||||
0 2px 7px rgba(0,0,0,.24),
|
||||
0 0 var(--glow-core) rgba(var(--blue-rgb), var(--glow-core-a)),
|
||||
0 0 var(--glow-blur) rgba(var(--blue-rgb), var(--glow-outer-a));
|
||||
}
|
||||
.section[data-accent="2"] .tile:hover{
|
||||
box-shadow:
|
||||
0 2px 7px rgba(0,0,0,.24),
|
||||
0 0 var(--glow-core) rgba(var(--green-rgb), var(--glow-core-a)),
|
||||
0 0 var(--glow-blur) rgba(var(--green-rgb), var(--glow-outer-a));
|
||||
}
|
||||
.section[data-accent="3"] .tile:hover{
|
||||
box-shadow:
|
||||
0 2px 7px rgba(0,0,0,.24),
|
||||
0 0 var(--glow-core) rgba(var(--red-rgb), var(--glow-core-a)),
|
||||
0 0 var(--glow-blur) rgba(var(--red-rgb), var(--glow-outer-a));
|
||||
}
|
||||
.section[data-accent="4"] .tile:hover{
|
||||
box-shadow:
|
||||
0 2px 7px rgba(0,0,0,.24),
|
||||
0 0 var(--glow-core) rgba(var(--yellow-rgb), var(--glow-core-a)),
|
||||
0 0 var(--glow-blur) rgba(var(--yellow-rgb), var(--glow-outer-a));
|
||||
}
|
||||
|
||||
/* custom-accent hover */
|
||||
.section.custom-accent .tile:hover{
|
||||
box-shadow:
|
||||
0 2px 7px rgba(0,0,0,.24),
|
||||
0 0 var(--glow-core) rgba(var(--accent-rgb), var(--glow-core-a)),
|
||||
0 0 var(--glow-blur) rgba(var(--accent-rgb), var(--glow-outer-a));
|
||||
}
|
||||
|
||||
/* Item title (white, normal case, configurable size) */
|
||||
.tile > div:nth-child(2){ grid-area:title; display:grid; place-items:start center; text-align:center; }
|
||||
.label{
|
||||
margin:0;
|
||||
font-family:var(--sans);
|
||||
font-weight:700; font-style:normal;
|
||||
letter-spacing:.05px; line-height:1.1;
|
||||
font-size: calc(var(--item-label-px) * 1px);
|
||||
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
||||
color:var(--ink); text-decoration:none;
|
||||
}
|
||||
.sub{ display:none; }
|
||||
|
||||
/* Icon block — big & crisp (configurable box size) */
|
||||
.icon{
|
||||
grid-area:icon; place-self:end center;
|
||||
width: var(--icon-box-pct); height: var(--icon-box-pct);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:clamp(24px, 9vw, 48px);
|
||||
line-height:1; margin-bottom:4px; background:none;
|
||||
}
|
||||
.icon img, .icon svg{
|
||||
display:block; max-width:100%; max-height:100%;
|
||||
object-fit:contain; image-rendering:auto; -ms-interpolation-mode:bicubic;
|
||||
transform: scale(var(--icon-scale, 1)); transform-origin:center bottom;
|
||||
}
|
||||
.icon svg{ shape-rendering:geometricPrecision; text-rendering:optimizeLegibility }
|
||||
|
||||
/* Status dot */
|
||||
.dot{
|
||||
--ok:#00e676; --down:#ff3d57;
|
||||
position:absolute; left:5px; bottom:5px;
|
||||
width:7px; height:7px; border-radius:50%; background:var(--ok);
|
||||
}
|
||||
.dot.down{ background:var(--down); }
|
||||
|
||||
/* Focus states (accessibility) */
|
||||
.section[data-accent="1"] .tile:focus-visible{ outline:2px solid var(--blue); outline-offset:2px; }
|
||||
.section[data-accent="2"] .tile:focus-visible{ outline:2px solid var(--green); outline-offset:2px; }
|
||||
.section[data-accent="3"] .tile:focus-visible{ outline:2px solid var(--red); outline-offset:2px; }
|
||||
.section[data-accent="4"] .tile:focus-visible{ outline:2px solid var(--yellow); outline-offset:2px; }
|
||||
.section.custom-accent .tile:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce){
|
||||
* { transition: none !important; }
|
||||
.tile:hover{ transform:none; }
|
||||
}
|
||||