From 7b6c135cd3f702ae2f23467bf090cd2671010ef1 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 27 Jun 2025 22:47:24 +0000 Subject: [PATCH] update --- changelog.md | 13 + test-output.log | 72 +++++ test/test.tabs-indicator.browser.ts | 146 ++++++++++ ts_web/elements/dees-appui-tabs.ts | 425 ++++++++++++++++++++-------- 4 files changed, 543 insertions(+), 113 deletions(-) create mode 100644 test-output.log create mode 100644 test/test.tabs-indicator.browser.ts diff --git a/changelog.md b/changelog.md index b5c3dca..647d328 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,18 @@ # Changelog +## 2025-06-27 - 1.10.8 - feat(ui-components) +Update multiple components with shadcn-aligned styling and improved animations + +- Updated dees-modal with shadcn colors, borders, and subtle shadows +- Updated dees-chips with shadcn styling and fixed selection logic bug +- Updated dees-dataview-codebox with shadcn syntax highlighting colors and responsive label layout +- Updated dees-input-multitoggle with transparent blue indicator and smooth animations +- Updated dees-appui-tabs with animated sliding indicator for both horizontal and vertical layouts +- Fixed indicator positioning to be perfectly centered on tab content +- Indicator width is content width + 8px for minimal visual padding +- Adjusted tab spacing to start more to the left with tighter padding +- Improved overall spacing and visual consistency across components + ## 2025-06-27 - 1.10.1 - fix(modal) Improve modal overscroll behavior by adding 'overscroll-behavior: contain' to content container diff --git a/test-output.log b/test-output.log new file mode 100644 index 0000000..25476f4 --- /dev/null +++ b/test-output.log @@ -0,0 +1,72 @@ + +> @design.estate/dees-catalog@1.10.8 test /mnt/data/lossless/design.estate/dees-catalog +> tstest test/ --web --verbose --timeout 30 --logfile test/test.tabs-indicator.browser.ts + + +πŸ” Test Discovery + Mode: file + Pattern: test/test.tabs-indicator.browser.ts + Found: 1 test file(s) + +▢️ test/test.tabs-indicator.browser.ts (1/1) + Runtime: chromium +running spawned compilation process +=======> ESBUILD +{ + cwd: '/mnt/data/lossless/design.estate/dees-catalog', + from: 'test/test.tabs-indicator.browser.ts', + to: '/mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/test__test.tabs-indicator.browser.ts.js', + mode: 'test', + argv: { bundler: 'esbuild' } +} +switched to /mnt/data/lossless/design.estate/dees-catalog +building for test: +Got no SSL certificates. Please ensure encryption using e.g. a reverse proxy +"/test" maps to 1 handlers + -> GET +"*" maps to 1 handlers + -> GET +now listening on 3007! +Launching puppeteer browser with arguments: +[] +Using executable: /usr/bin/google-chrome +added connection. now 1 sockets connected. +added connection. now 2 sockets connected. +connection ended +removed connection. 1 sockets remaining. +connection ended +removed connection. 0 sockets remaining. +added connection. now 1 sockets connected. +/favicon.ico +could not resolve /mnt/data/lossless/design.estate/dees-catalog/.nogit/tstest_cache/favicon.ico +/test__test.tabs-indicator.browser.ts.js + Test starting: tabs indicator positioning debug + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + Using globalThis.tapPromise + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! +connection ended +removed connection. 0 sockets remaining. +=>  Stopped test/test.tabs-indicator.browser.ts chromium instance and server. + +⚠️ Error + Only 0 out of 1 completed! + +⚠️ Error + The amount of received tests and expectedTests is unequal! Therefore the testfile failed + Summary: -1 passed, 1 failed of 0 tests in 2.7s + +πŸ“Š Test Summary +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Total Files: 1 β”‚ +β”‚ Total Tests: 0 β”‚ +β”‚ Passed: 0 β”‚ +β”‚ Failed: 0 β”‚ +β”‚ Duration: 4.2s β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +⏱️ Performance Metrics: + Average per test: 0ms + +ALL TESTS PASSED! πŸŽ‰ +Exited NOT OK! + ELIFECYCLE  Test failed. See above for more details. diff --git a/test/test.tabs-indicator.browser.ts b/test/test.tabs-indicator.browser.ts new file mode 100644 index 0000000..f9d1251 --- /dev/null +++ b/test/test.tabs-indicator.browser.ts @@ -0,0 +1,146 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as deesCatalog from '../ts_web/index.js'; + +tap.test('tabs indicator positioning - detailed measurements', async () => { + // Create tabs element with different length labels + const tabsElement = new deesCatalog.DeesAppuiTabs(); + tabsElement.tabs = [ + { key: 'Home', iconName: 'lucide:home', action: () => {} }, + { key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => {} }, + { key: 'User Settings', iconName: 'lucide:settings', action: () => {} }, + ]; + + document.body.appendChild(tabsElement); + await tabsElement.updateComplete; + + // Wait for fonts and indicator initialization + await new Promise(resolve => setTimeout(resolve, 200)); + + // Get all elements + const shadowRoot = tabsElement.shadowRoot; + const wrapper = shadowRoot.querySelector('.tabs-wrapper') as HTMLElement; + const container = shadowRoot.querySelector('.tabsContainer') as HTMLElement; + const tabs = shadowRoot.querySelectorAll('.tab'); + const firstTab = tabs[0] as HTMLElement; + const firstContent = firstTab.querySelector('.tab-content') as HTMLElement; + const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement; + + // Verify all elements exist + expect(wrapper).toBeInstanceOf(HTMLElement); + expect(container).toBeInstanceOf(HTMLElement); + expect(firstTab).toBeInstanceOf(HTMLElement); + expect(firstContent).toBeInstanceOf(HTMLElement); + expect(indicator).toBeInstanceOf(HTMLElement); + + // Get all measurements + const wrapperRect = wrapper.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const tabRect = firstTab.getBoundingClientRect(); + const contentRect = firstContent.getBoundingClientRect(); + const indicatorRect = indicator.getBoundingClientRect(); + + console.log('\n=== DETAILED MEASUREMENTS ==='); + console.log('Document body left:', document.body.getBoundingClientRect().left); + console.log('Wrapper left:', wrapperRect.left); + console.log('Container left:', containerRect.left); + console.log('Tab left:', tabRect.left); + console.log('Content left:', contentRect.left); + console.log('Indicator left (actual):', indicatorRect.left); + + console.log('\n=== RELATIVE POSITIONS ==='); + console.log('Container padding (container - wrapper):', containerRect.left - wrapperRect.left); + console.log('Tab position in container:', tabRect.left - containerRect.left); + console.log('Content position in tab:', contentRect.left - tabRect.left); + console.log('Content relative to wrapper:', contentRect.left - wrapperRect.left); + console.log('Indicator relative to wrapper (actual):', indicatorRect.left - wrapperRect.left); + + console.log('\n=== WIDTHS ==='); + console.log('Tab width:', tabRect.width); + console.log('Content width:', contentRect.width); + console.log('Indicator width:', indicatorRect.width); + + console.log('\n=== STYLES (what we set) ==='); + console.log('Indicator style.left:', indicator.style.left); + console.log('Indicator style.width:', indicator.style.width); + + console.log('\n=== CALCULATIONS ==='); + const expectedIndicatorLeft = contentRect.left - wrapperRect.left - 4; // We subtract 4 to center + const expectedIndicatorWidth = contentRect.width + 8; // We add 8 in the code + console.log('Expected indicator left:', expectedIndicatorLeft); + console.log('Expected indicator width:', expectedIndicatorWidth); + console.log('Actual indicator left (from style):', parseFloat(indicator.style.left)); + console.log('Actual indicator width (from style):', parseFloat(indicator.style.width)); + + console.log('\n=== VISUAL ALIGNMENT CHECK ==='); + const tabCenter = tabRect.left + (tabRect.width / 2); + const contentCenter = contentRect.left + (contentRect.width / 2); + const indicatorCenter = indicatorRect.left + (indicatorRect.width / 2); + + console.log('Tab center:', tabCenter); + console.log('Content center:', contentCenter); + console.log('Indicator center:', indicatorCenter); + console.log('Content offset from tab center:', contentCenter - tabCenter); + console.log('Indicator offset from content center:', indicatorCenter - contentCenter); + console.log('Indicator offset from tab center:', indicatorCenter - tabCenter); + console.log('---'); + console.log('Indicator extends left of content by:', contentRect.left - indicatorRect.left); + console.log('Indicator extends right of content by:', (indicatorRect.left + indicatorRect.width) - (contentRect.left + contentRect.width)); + + // Check if icons are rendering + const icon = firstContent.querySelector('dees-icon'); + console.log('\n=== ICON CHECK ==='); + console.log('Icon element found:', icon ? 'YES' : 'NO'); + if (icon) { + const iconRect = icon.getBoundingClientRect(); + console.log('Icon width:', iconRect.width); + console.log('Icon height:', iconRect.height); + console.log('Icon visible:', iconRect.width > 0 && iconRect.height > 0 ? 'YES' : 'NO'); + } + + // Verify indicator is visible + expect(indicator.style.opacity).toEqual('1'); + + // Verify positioning calculations + expect(parseFloat(indicator.style.left)).toBeCloseTo(expectedIndicatorLeft, 1); + expect(parseFloat(indicator.style.width)).toBeCloseTo(expectedIndicatorWidth, 1); + + // Verify visual centering on content (should be perfectly centered) + expect(Math.abs(indicatorCenter - contentCenter)).toBeLessThan(1); + + document.body.removeChild(tabsElement); +}); + +tap.test('tabs indicator should move when tab is clicked', async () => { + // Create tabs element + const tabsElement = new deesCatalog.DeesAppuiTabs(); + tabsElement.tabs = [ + { key: 'Home', iconName: 'lucide:home', action: () => {} }, + { key: 'Analytics', iconName: 'lucide:barChart', action: () => {} }, + { key: 'Settings', iconName: 'lucide:settings', action: () => {} }, + ]; + + document.body.appendChild(tabsElement); + await tabsElement.updateComplete; + await new Promise(resolve => setTimeout(resolve, 100)); + + const shadowRoot = tabsElement.shadowRoot; + const tabs = shadowRoot.querySelectorAll('.tab'); + const indicator = shadowRoot.querySelector('.tabIndicator') as HTMLElement; + + // Get initial position + const initialLeft = parseFloat(indicator.style.left); + + // Click second tab + (tabs[1] as HTMLElement).click(); + await tabsElement.updateComplete; + await new Promise(resolve => setTimeout(resolve, 100)); + + // Position should have changed + const newLeft = parseFloat(indicator.style.left); + expect(newLeft).not.toEqual(initialLeft); + expect(newLeft).toBeGreaterThan(initialLeft); + + document.body.removeChild(tabsElement); +}); + +export default tap.start(); \ No newline at end of file diff --git a/ts_web/elements/dees-appui-tabs.ts b/ts_web/elements/dees-appui-tabs.ts index 4951da5..66a40c6 100644 --- a/ts_web/elements/dees-appui-tabs.ts +++ b/ts_web/elements/dees-appui-tabs.ts @@ -15,13 +15,92 @@ import * as domtools from '@design.estate/dees-domtools'; @customElement('dees-appui-tabs') export class DeesAppuiTabs extends DeesElement { public static demo = () => html` - console.log('Tab 1 clicked') }, - { key: 'Tab 2', action: () => console.log('Tab 2 clicked') }, - { key: 'Tab 3', action: () => console.log('Tab 3 clicked') }, - ]} - > + +
+
+
Horizontal Tabs with Animated Indicator
+ console.log('Home clicked') }, + { key: 'Analytics Dashboard', iconName: 'lucide:lineChart', action: () => console.log('Analytics clicked') }, + { key: 'Reports', iconName: 'lucide:fileText', action: () => console.log('Reports clicked') }, + { key: 'User Settings', iconName: 'lucide:settings', action: () => console.log('Settings clicked') }, + { key: 'Help', iconName: 'lucide:helpCircle', action: () => console.log('Help clicked') }, + ]} + > +
+ Select a tab to see the smooth sliding animation of the indicator. The indicator automatically adjusts its width to match the tab content with minimal padding. +
+
+
+ +
+
Vertical Tabs Layout
+
+ console.log('Profile clicked') }, + { key: 'Security', iconName: 'lucide:shield', action: () => console.log('Security clicked') }, + { key: 'Notifications', iconName: 'lucide:bell', action: () => console.log('Notifications clicked') }, + { key: 'Integrations', iconName: 'lucide:link', action: () => console.log('Integrations clicked') }, + { key: 'Advanced', iconName: 'lucide:code', action: () => console.log('Advanced clicked') }, + ]} + > +
+ Vertical tabs work great for settings pages and navigation menus. The animated indicator smoothly transitions between selections. +
+
+
+ +
+
Without Indicator
+ console.log('All clicked') }, + { key: 'Active', action: () => console.log('Active clicked') }, + { key: 'Completed', action: () => console.log('Completed clicked') }, + { key: 'Archived', action: () => console.log('Archived clicked') }, + ]} + > +
+ Tabs can also be used without the animated indicator by setting showTabIndicator to false. +
+
+
+
`; // INSTANCE @@ -50,148 +129,217 @@ export class DeesAppuiTabs extends DeesElement { .tabs-wrapper { position: relative; - background: ${cssManager.bdTheme('#f5f5f5', '#000000')}; - height: 52px; + } + + .tabs-wrapper.horizontal-wrapper { + border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', '#27272a')}; } .tabsContainer { position: relative; - z-index: 1; user-select: none; } .tabsContainer.horizontal { - display: grid; - padding-top: 20px; - padding-bottom: 0px; - margin-left: 24px; + display: flex; + align-items: center; font-size: 14px; + overflow-x: auto; + scrollbar-width: none; + height: 48px; + padding: 0 16px; + gap: 4px; + } + + .tabsContainer.horizontal::-webkit-scrollbar { + display: none; } .tabsContainer.vertical { display: flex; flex-direction: column; - padding: 20px; + padding: 8px; font-size: 14px; + gap: 2px; + position: relative; + background: ${cssManager.bdTheme('#f9fafb', '#18181b')}; + border-radius: 8px; } .tab { - color: ${cssManager.bdTheme('#666', '#a0a0a0')}; + color: ${cssManager.bdTheme('#71717a', '#71717a')}; white-space: nowrap; - cursor: default; - transition: color 0.1s; + cursor: pointer; + transition: color 0.15s ease; + font-weight: 500; + position: relative; + z-index: 2; } .horizontal .tab { - margin-right: 30px; - padding-top: 4px; - padding-bottom: 12px; + padding: 0 16px; + height: 100%; + display: inline-flex; + align-items: center; + gap: 8px; + position: relative; + border-radius: 6px 6px 0 0; + transition: background-color 0.15s ease; } - - .vertical .tab { - padding: 12px 16px; - margin-bottom: 4px; - border-radius: 4px; - width: 100%; - display: flex; + + .horizontal .tab:not(:last-child)::after { + content: ''; + position: absolute; + right: -2px; + top: 50%; + transform: translateY(-50%); + height: 20px; + width: 1px; + background: ${cssManager.bdTheme('#e5e7eb', '#27272a')}; + opacity: 0.5; + } + + .horizontal .tab .tab-content { + display: inline-flex; align-items: center; gap: 8px; } + .vertical .tab { + padding: 10px 16px; + border-radius: 6px; + width: 100%; + display: flex; + align-items: center; + gap: 8px; + transition: all 0.15s ease; + } + .tab:hover { - color: ${cssManager.bdTheme('#000', '#ffffff')}; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + } + + .horizontal .tab:hover { + background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)')}; + } + + .horizontal .tab:hover::after, + .horizontal .tab:hover + .tab::after { + opacity: 0; } .vertical .tab:hover { - background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)')}; + background: ${cssManager.bdTheme('rgba(244, 244, 245, 0.5)', 'rgba(39, 39, 42, 0.5)')}; } - .tab.selectedTab { - color: ${cssManager.bdTheme('#333', '#e0e0e0')}; + .horizontal .tab.selectedTab { + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; + } + + .horizontal .tab.selectedTab::after, + .horizontal .tab.selectedTab + .tab::after { + opacity: 0; } .vertical .tab.selectedTab { - background: ${cssManager.bdTheme('rgba(0, 0, 0, 0.1)', 'rgba(255, 255, 255, 0.1)')}; - color: ${cssManager.bdTheme('#000', '#ffffff')}; + color: ${cssManager.bdTheme('#09090b', '#fafafa')}; } .tab dees-icon { font-size: 16px; } - .tabs-wrapper .tabIndicator { + .tabIndicator { position: absolute; - z-index: 0; - left: 40px; - bottom: 0px; - height: 40px; - width: 40px; - background: ${cssManager.bdTheme('#ffffff', '#161616')}; - transition: all 0.1s; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - border-top: 1px solid ${cssManager.bdTheme('#e0e0e0', '#444444')}; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + opacity: 0; + } + + .tabIndicator.no-transition { + transition: none; } - .vertical .tabIndicator { - display: none; + .tabs-wrapper .tabIndicator { + height: 3px; + bottom: 0; + background: ${cssManager.bdTheme('#3b82f6', '#3b82f6')}; + border-radius: 3px 3px 0 0; + z-index: 3; + } + + .vertical-wrapper { + position: relative; + } + + .vertical-wrapper .tabIndicator { + left: 8px; + right: 8px; + border-radius: 6px; + background: ${cssManager.bdTheme('#ffffff', '#27272a')}; + z-index: 1; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); } .content { - margin-top: 20px; + padding: 32px 24px; } `, ]; public render(): TemplateResult { return html` - ${this.tabStyle === 'horizontal' ? html` - -
-
- ${this.tabs.map((tabArg) => { - return html` -
- ${tabArg.key} -
- `; - })} -
- ${this.showTabIndicator ? html` -
- ` : ''} -
- ` : html` -
- ${this.tabs.map((tabArg) => { - return html` -
- ${tabArg.iconName ? html`` : ''} - ${tabArg.key} -
- `; - })} -
- `} + ${this.renderTabsWrapper()}
`; } + private renderTabsWrapper(): TemplateResult { + const isHorizontal = this.tabStyle === 'horizontal'; + const wrapperClass = isHorizontal ? 'tabs-wrapper horizontal-wrapper' : 'vertical-wrapper'; + const containerClass = `tabsContainer ${this.tabStyle}`; + + return html` +
+
+ ${this.tabs.map(tab => this.renderTab(tab, isHorizontal))} +
+ ${this.showTabIndicator ? html`
` : ''} +
+ `; + } + + private renderTab(tab: interfaces.ITab, isHorizontal: boolean): TemplateResult { + const isSelected = tab === this.selectedTab; + const classes = `tab ${isSelected ? 'selectedTab' : ''}`; + + const content = isHorizontal ? html` + + ${this.renderTabIcon(tab)} + ${tab.key} + + ` : html` + ${this.renderTabIcon(tab)} + ${tab.key} + `; + + return html` +
+ ${content} +
+ `; + } + + private renderTabIcon(tab: interfaces.ITab): TemplateResult | '' { + return tab.iconName ? html`` : ''; + } + private selectTab(tabArg: interfaces.ITab) { this.selectedTab = tabArg; - this.updateTabIndicator(); tabArg.action(); // Emit tab-select event @@ -202,31 +350,6 @@ export class DeesAppuiTabs extends DeesElement { })); } - /** - * updates the indicator position - */ - private updateTabIndicator() { - if (!this.showTabIndicator || this.tabStyle !== 'horizontal' || !this.selectedTab) { - return; - } - - const tabIndex = this.tabs.indexOf(this.selectedTab); - const selectedTabElement: HTMLElement = this.shadowRoot.querySelector( - `.tabs-wrapper .tabsContainer .tab:nth-child(${tabIndex + 1})` - ); - - if (!selectedTabElement) return; - - const tabsContainer: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabsContainer'); - const marginLeft = parseInt(window.getComputedStyle(tabsContainer).getPropertyValue("margin-left")); - const tabIndicator: HTMLElement = this.shadowRoot.querySelector('.tabs-wrapper .tabIndicator'); - - if (tabIndicator) { - tabIndicator.style.width = selectedTabElement.clientWidth + 24 + 'px'; - tabIndicator.style.left = selectedTabElement.offsetLeft + marginLeft - 12 + 'px'; - } - } - firstUpdated() { if (this.tabs && this.tabs.length > 0) { this.selectTab(this.tabs[0]); @@ -241,7 +364,83 @@ export class DeesAppuiTabs extends DeesElement { } if (changedProperties.has('selectedTab') || changedProperties.has('tabs')) { - this.updateTabIndicator(); + await this.updateComplete; + // Wait for fonts to load on first update + if (!this.indicatorInitialized && document.fonts) { + await document.fonts.ready; + } + requestAnimationFrame(() => { + this.updateTabIndicator(); + }); } } + + private indicatorInitialized = false; + + private updateTabIndicator() { + if (!this.selectedTab || !this.showTabIndicator) { + return; + } + + const selectedIndex = this.tabs.indexOf(this.selectedTab); + if (selectedIndex === -1) { + return; + } + + // Select the correct tab element using nth-child + const selector = this.tabStyle === 'horizontal' + ? `.tabs-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})` + : `.vertical-wrapper .tabsContainer .tab:nth-child(${selectedIndex + 1})`; + + const selectedTabElement = this.shadowRoot.querySelector(selector) as HTMLElement; + + if (!selectedTabElement) { + return; + } + + const indicator = this.shadowRoot.querySelector('.tabIndicator') as HTMLElement; + if (!indicator) { + return; + } + + // Disable transition for initial positioning + if (!this.indicatorInitialized) { + indicator.classList.add('no-transition'); + this.indicatorInitialized = true; + + setTimeout(() => { + indicator.classList.remove('no-transition'); + }, 50); + } + + if (this.tabStyle === 'horizontal') { + const tabContent = selectedTabElement.querySelector('.tab-content') as HTMLElement; + + if (tabContent) { + // Use getBoundingClientRect for accurate positioning + const wrapperRect = indicator.parentElement.getBoundingClientRect(); + const contentRect = tabContent.getBoundingClientRect(); + + // Calculate the position relative to wrapper + const contentLeft = contentRect.left - wrapperRect.left; + + // Set width to match content plus small padding + const indicatorWidth = contentRect.width + 8; + + // Center the indicator on content (shift left by half the extra width) + const indicatorLeft = contentLeft - 4; + + indicator.style.width = `${indicatorWidth}px`; + indicator.style.left = `${indicatorLeft}px`; + } + } else { + // For vertical tabs + const tabsContainer = this.shadowRoot.querySelector('.vertical-wrapper .tabsContainer') as HTMLElement; + + indicator.style.top = `${selectedTabElement.offsetTop + tabsContainer.offsetTop}px`; + indicator.style.height = `${selectedTabElement.clientHeight}px`; + } + + indicator.style.opacity = '1'; + } } \ No newline at end of file