diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js index de7b92446..b9332e10d 100644 --- a/source/skills/impeccable/scripts/live-browser.js +++ b/source/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1526,11 +1682,12 @@ }; function loadDesignPrefs() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. try { const raw = localStorage.getItem(DESIGN_PREFS_KEY); if (!raw) return; const prefs = JSON.parse(raw); - if (typeof prefs.open === 'boolean') designState.open = prefs.open; if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab; if (prefs.collapsed && typeof prefs.collapsed === 'object') { Object.assign(designState.collapsed, prefs.collapsed); @@ -1541,7 +1698,7 @@ function saveDesignPrefs() { try { localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({ - open: designState.open, tab: designState.tab, + tab: designState.tab, collapsed: designState.collapsed, })); } catch { /* ignore */ } @@ -1559,7 +1716,9 @@ designShadow = designHost.attachShadow({ mode: 'open' }); const style = document.createElement('style'); - style.textContent = designPanelCss(); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); designShadow.appendChild(style); const root = document.createElement('div'); @@ -1590,7 +1749,9 @@ amberBg: 'oklch(95% 0.05 80)', }; - function designPanelCss() { + function designPanelCss(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). return ` :host, .root { all: initial; } .root { @@ -1601,38 +1762,15 @@ .root * { box-sizing: border-box; } button { font: inherit; color: inherit; } - /* --- FAB --- */ - .fab { - position: fixed; right: 16px; bottom: 16px; - display: inline-flex; align-items: center; gap: 8px; - padding: 8px 14px 8px 10px; - background: ${DP.tile}; - color: ${DP.ink}; - border: 1px solid ${DP.hairline}; - border-radius: 999px; - box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06); - font-family: ${FONT}; font-size: 11px; font-weight: 600; - letter-spacing: 0.08em; text-transform: uppercase; - cursor: pointer; pointer-events: auto; - transition: transform 0.2s ${EASE}, box-shadow 0.2s ${EASE}, background 0.2s ${EASE}; - } - .fab:hover { transform: translateY(-1px); - box-shadow: 0 8px 24px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08); } - .fab[data-open="true"] { display: none; } - .fab-swatches { - width: 16px; height: 16px; border-radius: 3px; - display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr; - overflow: hidden; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.1); - } - .fab-swatches > span { display: block; } - - /* --- Panel shell --- */ + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ .panel { - position: fixed; top: 12px; bottom: 12px; right: 12px; + position: fixed; top: 12px; bottom: 72px; right: 12px; width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); - background: ${DP.canvas}; + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; border-radius: 14px; - box-shadow: 0 20px 60px oklch(0% 0 0 / 0.14), 0 2px 8px oklch(0% 0 0 / 0.06); + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); display: flex; flex-direction: column; transform: translateX(calc(100% + 24px)); opacity: 0; @@ -1644,43 +1782,47 @@ .panel-header { display: flex; align-items: center; gap: 10px; - padding: 14px 12px 12px 18px; - background: ${DP.canvas}; + padding: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; } .panel-title { flex: 1; min-width: 0; - font-size: 13px; font-weight: 600; - color: ${DP.ink}; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .panel-close { - border: none; background: transparent; color: ${DP.meta}; - width: 28px; height: 28px; border-radius: 8px; + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; } - .panel-close:hover { background: oklch(90% 0 0); color: ${DP.ink}; } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } .tabs { display: inline-flex; padding: 2px; - background: oklch(90% 0 0); - border-radius: 8px; + background: ${BP.hairline}; + border-radius: 7px; gap: 2px; } .tab { border: none; background: transparent; - padding: 4px 12px; border-radius: 6px; - font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; - color: ${DP.meta}; cursor: pointer; + color: ${BP.textDim}; cursor: pointer; transition: background 0.15s ease, color 0.15s ease; } - .tab[data-active="true"] { background: ${DP.tile}; color: ${DP.ink}; - box-shadow: 0 1px 2px oklch(0% 0 0 / 0.08); } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } .panel-body { flex: 1; overflow-y: auto; - padding: 4px 12px 20px; + padding: 12px 12px 20px; + background: ${DP.canvas}; scrollbar-width: thin; scrollbar-color: ${DP.hairline} transparent; } @@ -1887,23 +2029,7 @@ const root = designShadow.querySelector('.root'); root.innerHTML = ''; - // FAB button - const fab = document.createElement('button'); - fab.className = 'fab'; - fab.setAttribute('data-open', designState.open ? 'true' : 'false'); - fab.setAttribute('aria-label', 'Toggle Design System panel'); - fab.innerHTML = ` - - - - - - - Design - `; - fab.addEventListener('click', toggleDesignPanel); - root.appendChild(fab); - + // (Panel toggle lives in the global bar — no floating FAB.) // Panel const panel = document.createElement('aside'); panel.className = 'panel'; @@ -1924,9 +2050,7 @@ const title = document.createElement('div'); title.className = 'panel-title'; - title.textContent = designState.model?.title - || designState.parsedMd?.title - || 'Design System'; + title.textContent = 'DESIGN.md'; header.appendChild(title); const tabs = document.createElement('div'); @@ -1961,8 +2085,8 @@ function toggleDesignPanel() { designState.open = !designState.open; - saveDesignPrefs(); renderDesignChrome(); + updateGlobalBarState(); if (designState.open && designState.present === null && !designState.loading) { fetchDesignSystem(); } diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index b176d9f05..387a0fdf5 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs index 5df033aaa..04a054c2a 100644 --- a/source/skills/impeccable/scripts/live-server.mjs +++ b/source/skills/impeccable/scripts/live-server.mjs @@ -102,8 +102,12 @@ function loadBrowserScripts() { } function hasProjectContext() { + // PRODUCT.md carries brand voice / anti-references — that's what determines + // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate + // concern, surfaced by the design panel's own empty state. Legacy + // .impeccable.md is auto-migrated to PRODUCT.md by load-context.mjs. try { - fs.accessSync(path.join(process.cwd(), '.impeccable.md'), fs.constants.R_OK); + fs.accessSync(path.join(process.cwd(), 'PRODUCT.md'), fs.constants.R_OK); return true; } catch { return false; } }