From aa254433c96060ebf81377a9f7ac443888ccdc18 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 05:20:50 +0900 Subject: [PATCH 1/8] test(ui): specify generated-index readability contract --- .../GeneratedIndexReadabilityTest.kt | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt diff --git a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt new file mode 100644 index 00000000..2beb577c --- /dev/null +++ b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt @@ -0,0 +1,186 @@ +package html4tree + +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.math.pow +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Product-level regressions for the CSS and markup emitted into a generated + * directory index. + * + * These tests inspect the real `index.html` output rather than an independent + * stylesheet fixture, so a template or CSP-byte regression cannot be hidden by + * a test-only copy of the CSS. + */ +class GeneratedIndexReadabilityTest { + private lateinit var temporaryDirectory: File + + @Before + fun createTemporaryDirectory() { + temporaryDirectory = Files.createTempDirectory("html4tree-readability-").toFile() + } + + @After + fun removeTemporaryDirectory() { + temporaryDirectory.deleteRecursively() + } + + @Test + fun generatedRowsPreserveFirstMiddleAndLastOrder() { + val firstFile = File(temporaryDirectory, "alpha.txt").apply { writeText("alpha") } + val middleFile = File(temporaryDirectory, "middle.txt").apply { writeText("middle") } + val lastFile = File(temporaryDirectory, "zulu.txt").apply { writeText("zulu") } + + process_dir( + temporaryDirectory, + setOf("index.html"), + arrayOf(lastFile, firstFile, middleFile) + ) + + val generatedHtml = generatedHtml() + val parentIndex = generatedHtml.indexOf("..") + val firstIndex = generatedHtml.indexOf("alpha.txt") + val middleIndex = generatedHtml.indexOf("middle.txt") + val lastIndex = generatedHtml.indexOf("zulu.txt") + + assertTrue(parentIndex >= 0) + assertTrue(parentIndex < firstIndex) + assertTrue(firstIndex < middleIndex) + assertTrue(middleIndex < lastIndex) + assertFalse(generatedHtml.contains("이 디렉토리는 비어 있습니다.")) + } + + @Test + fun emptyDirectoryRetainsOneSemanticStatusRow() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val generatedHtml = generatedHtml() + val expectedEmptyRow = + """
  • 이 디렉토리는 비어 있습니다.
  • """ + + assertTrue(generatedHtml.contains(expectedEmptyRow)) + assertTrue(generatedHtml.indexOf(expectedEmptyRow) == generatedHtml.lastIndexOf(expectedEmptyRow)) + } + + @Test + fun stylesheetSeparatesAdjacentRowsWithoutTrailingBorderRule() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + assertTrue( + style.contains( + """ + li + li { + border-top: 1px solid #d0d7de; + } + """.trimIndent() + ) + ) + assertFalse(style.contains("li:last-child")) + } + + @Test + fun emptyStateUsesExplicitLightAndDarkForegroundColors() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + val baseRule = + """ + .empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + color: #656d76; + font-style: italic; + } + """.trimIndent() + val darkModeMarker = "@media (prefers-color-scheme: dark)" + val darkRule = + """ + .empty-dir { + color: #8b949e; + } + """.trimIndent() + + val baseRuleIndex = style.indexOf(baseRule) + val darkModeIndex = style.indexOf(darkModeMarker) + val darkRuleIndex = style.indexOf(darkRule, startIndex = darkModeIndex.coerceAtLeast(0)) + + assertTrue(baseRuleIndex >= 0) + assertFalse(style.contains("opacity:")) + assertTrue(darkModeIndex > baseRuleIndex) + assertTrue(darkRuleIndex > darkModeIndex) + } + + @Test + fun hoverAndKeyboardFocusUnderlineOnlyLinkText() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + val completeTargetRule = Regex("""a:hover, a:focus-visible \{([\s\S]*?)\}""") + .find(style) + ?.groupValues + ?.get(1) + assertNotNull(completeTargetRule) + assertFalse(completeTargetRule.contains("text-decoration")) + assertTrue(completeTargetRule.contains("outline: 2px solid #0969da;")) + assertTrue( + style.contains( + """ + a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; + } + """.trimIndent() + ) + ) + assertTrue(style.contains("@media (prefers-reduced-motion: reduce)")) + } + + @Test + fun authoredColorsMeetDocumentedContrastThresholds() { + assertTrue(contrastRatio("#656d76", "#ffffff") >= 4.5) + assertTrue(contrastRatio("#8b949e", "#0d1117") >= 4.5) + assertTrue(contrastRatio("#0969da", "#f6f8fa") >= 3.0) + assertTrue(contrastRatio("#58a6ff", "#161b22") >= 3.0) + } + + private fun generatedHtml(): String = + File(temporaryDirectory, "index.html").readText(Charsets.UTF_8) + + private fun emittedStyle(): String { + val style = Regex("""""") + .find(generatedHtml()) + ?.groupValues + ?.get(1) + return requireNotNull(style) { "Generated HTML must contain one inline style block" } + } + + private fun contrastRatio(foreground: String, background: String): Double { + val foregroundLuminance = relativeLuminance(foreground) + val backgroundLuminance = relativeLuminance(background) + val lighter = maxOf(foregroundLuminance, backgroundLuminance) + val darker = minOf(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) + } + + private fun relativeLuminance(hexColor: String): Double { + val channels = hexColor.removePrefix("#") + .chunked(2) + .map { it.toInt(16) / 255.0 } + .map { channel -> + if (channel <= 0.04045) { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).pow(2.4) + } + } + return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]) + } +} From 65d7147724acc4ad636e543d7ffcb7646b20ac6b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 05:23:21 +0900 Subject: [PATCH 2/8] feat(ui): improve generated-index readability --- src/main/kotlin/html4tree/main.kt | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/html4tree/main.kt b/src/main/kotlin/html4tree/main.kt index e93fbea7..f52a1468 100644 --- a/src/main/kotlin/html4tree/main.kt +++ b/src/main/kotlin/html4tree/main.kt @@ -50,15 +50,28 @@ a { } a:hover, a:focus-visible { background-color: #f6f8fa; - text-decoration: underline; outline: 2px solid #0969da; outline-offset: -2px; } +a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; +} @media (prefers-reduced-motion: reduce) { a { transition: none; } } +li + li { + border-top: 1px solid #d0d7de; +} +.empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + color: #656d76; + font-style: italic; +} @media (prefers-color-scheme: dark) { body { background-color: #0d1117; @@ -71,14 +84,12 @@ a:hover, a:focus-visible { background-color: #161b22; outline-color: #58a6ff; } -} -.empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.5rem; - opacity: 0.7; - font-style: italic; + li + li { + border-top-color: #21262d; + } + .empty-dir { + color: #8b949e; + } } """.trimIndent() From e14c21e15844bc2ec3602ce8ae4c8395b9b7f085 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 05:24:08 +0900 Subject: [PATCH 3/8] docs(ui): record generated-index readability contract --- docs/doctoring/generated-index-readability.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/doctoring/generated-index-readability.md diff --git a/docs/doctoring/generated-index-readability.md b/docs/doctoring/generated-index-readability.md new file mode 100644 index 00000000..590249b7 --- /dev/null +++ b/docs/doctoring/generated-index-readability.md @@ -0,0 +1,51 @@ +# Generated index readability and focus treatment + +## Decision + +The generated `index.html` page is html4tree's primary user-facing artifact. Its embedded stylesheet therefore applies the following bounded presentation contract without changing file discovery, ordering, escaping, URLs, filesystem access, or the Content Security Policy mechanism: + +- adjacent list rows are separated with `li + li`, so the first row has no unnecessary leading line and no special last-row override is required; +- the empty-directory status uses explicit foreground colors in light and dark color schemes instead of inherited color plus opacity; +- hover and keyboard-focus states underline only the link's textual span, while the existing two-CSS-pixel outline continues to surround the complete interactive target; +- reduced-motion behavior remains unchanged; and +- dark-mode overrides appear after their corresponding base declarations so the cascade is deterministic. + +The empty-directory information icon remains decorative, is hidden from assistive technology with `aria-hidden="true"`, and stays inside the existing `role="status"` container. No new emoji is selected solely for visual appearance. + +## Accessibility engineering basis + +WCAG 2.2 Success Criterion 1.4.3 requires at least 4.5:1 contrast for ordinary text. Success Criterion 2.4.13, which is Level AAA, describes a keyboard focus indicator area at least as large as a two-CSS-pixel perimeter and a focused-versus-unfocused contrast change of at least 3:1. The W3C Understanding documents explain these criteria but are informative rather than normative. + +html4tree retains a solid two-CSS-pixel outline around the full link. The engineering tests calculate the following sRGB relative-luminance ratios from the authored CSS colors: + +| Surface | Foreground or indicator | Background | Calculated ratio | Engineering threshold | +| --- | --- | --- | ---: | ---: | +| Empty-state text, light | `#656d76` | `#ffffff` | 5.2469:1 | 4.5:1 | +| Empty-state text, dark | `#8b949e` | `#0d1117` | 6.1527:1 | 4.5:1 | +| Focus outline, light hover/focus surface | `#0969da` | `#f6f8fa` | 4.8771:1 | 3:1 | +| Focus outline, dark hover/focus surface | `#58a6ff` | `#161b22` | 6.8480:1 | 3:1 | + +These deterministic source-level checks are regression evidence, not a declaration of formal WCAG conformance. Browser rendering, anti-aliasing, zoom, forced colors, operating-system settings, extensions, and assistive-technology behavior still require representative manual and browser-based evaluation. The subtle row separator is a readability aid; this decision does not claim that the separator itself satisfies every possible WCAG non-text-contrast interpretation. + +## Verification contract + +`GeneratedIndexReadabilityTest` exercises the real generated page and verifies: + +1. parent, first, middle, and last rows preserve the established output order; +2. an empty directory emits exactly one semantic status row; +3. adjacent-row separators do not rely on a trailing-border exception; +4. empty-state text uses explicit light and dark colors and no opacity declaration; +5. dark-mode declarations follow their base declarations; +6. hover and `:focus-visible` underline only the textual span while preserving the full-target outline; +7. reduced-motion styling remains present; and +8. the authored text and focus colors meet the documented numeric thresholds. + +`CspHashTest` independently recomputes the digest from the exact emitted ``와 같이 공백 없이 주입하여 해시가 완벽하게 일치하도록 해야 합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index ca420843..00000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,32 +0,0 @@ -# Changelog - -All notable changes to this project are documented in this file. - -## [Unreleased] - -### Changed - -- Improve generated directory-index readability with adjacent-row separators, - explicit light and dark empty-state text colors, and text-only hover/focus - underlining while retaining the full interactive target's focus outline. - -### Fixed - -- Generate the inline-style Content Security Policy SHA-256 source expression - from the exact normalized UTF-8 stylesheet bytes emitted into each generated - `index.html` file, preventing template whitespace from invalidating the policy. - -### Tests - -- Add a real generated-file regression test that independently recomputes the - declared style hash from the emitted ` + """ + val index_top = """ @@ -333,11 +329,11 @@ fun process_dir(curr_dir: File, excludeSet: Set? = null, dirFiles: Array - + ${curr_dir.getName().escapeHtml()} - + ${css}
    diff --git a/src/test/kotlin/html4tree/CspHashTest.kt b/src/test/kotlin/html4tree/CspHashTest.kt deleted file mode 100644 index 388e3155..00000000 --- a/src/test/kotlin/html4tree/CspHashTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package html4tree - -import java.io.File -import java.nio.file.Files -import java.security.MessageDigest -import java.util.Base64 -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -class CspHashTest { - @Test - fun emittedStyleBytesMatchTheDeclaredCspHash() { - val directory = Files.createTempDirectory("html4tree-csp-").toFile() - - try { - process_dir(directory, setOf("index.html"), emptyArray()) - - val html = File(directory, "index.html").readText(Charsets.UTF_8) - val styleContent = Regex("""""") - .find(html) - ?.groupValues - ?.get(1) - val declaredHash = Regex("""style-src 'sha256-([^']+)'""") - .find(html) - ?.groupValues - ?.get(1) - - assertNotNull(styleContent, "Generated HTML must contain one inline style block") - assertNotNull(declaredHash, "Generated HTML must declare a SHA-256 style source") - assertEquals(styleContent.trim(), styleContent, "Hashed style bytes must not gain template padding") - - val actualHash = Base64.getEncoder().encodeToString( - MessageDigest.getInstance("SHA-256") - .digest(styleContent.toByteArray(Charsets.UTF_8)) - ) - assertEquals(declaredHash, actualHash) - assertTrue(styleContent.startsWith("body {")) - assertTrue(styleContent.endsWith("}")) - } finally { - directory.listFiles()?.forEach { it.delete() } - directory.delete() - } - } -} diff --git a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt deleted file mode 100644 index d78732c0..00000000 --- a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt +++ /dev/null @@ -1,181 +0,0 @@ -package html4tree - -import org.junit.After -import org.junit.Before -import org.junit.Test -import java.io.File -import java.nio.file.Files -import kotlin.math.pow -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * Product-level regressions for the CSS and markup emitted into a generated - * directory index. - * - * These tests inspect the real `index.html` output rather than an independent - * stylesheet fixture, so a template or CSP-byte regression cannot be hidden by - * a test-only copy of the CSS. - */ -class GeneratedIndexReadabilityTest { - private lateinit var temporaryDirectory: File - - @Before - fun createTemporaryDirectory() { - temporaryDirectory = Files.createTempDirectory("html4tree-readability-").toFile() - } - - @After - fun removeTemporaryDirectory() { - temporaryDirectory.deleteRecursively() - } - - @Test - fun generatedRowsPreserveFirstMiddleAndLastOrder() { - val firstFile = File(temporaryDirectory, "alpha.txt").apply { writeText("alpha") } - val middleFile = File(temporaryDirectory, "middle.txt").apply { writeText("middle") } - val lastFile = File(temporaryDirectory, "zulu.txt").apply { writeText("zulu") } - - process_dir( - temporaryDirectory, - setOf("index.html"), - arrayOf(lastFile, firstFile, middleFile) - ) - - val generatedHtml = generatedHtml() - val parentIndex = generatedHtml.indexOf("..") - val firstIndex = generatedHtml.indexOf("alpha.txt") - val middleIndex = generatedHtml.indexOf("middle.txt") - val lastIndex = generatedHtml.indexOf("zulu.txt") - - assertTrue(parentIndex >= 0) - assertTrue(parentIndex < firstIndex) - assertTrue(firstIndex < middleIndex) - assertTrue(middleIndex < lastIndex) - assertFalse(generatedHtml.contains("이 디렉토리는 비어 있습니다.")) - } - - @Test - fun emptyDirectoryRetainsOneSemanticStatusRow() { - process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) - - val generatedHtml = generatedHtml() - val expectedEmptyRow = - """
  • 이 디렉토리는 비어 있습니다.
  • """ - - assertTrue(generatedHtml.contains(expectedEmptyRow)) - assertTrue(generatedHtml.indexOf(expectedEmptyRow) == generatedHtml.lastIndexOf(expectedEmptyRow)) - } - - @Test - fun stylesheetSeparatesAdjacentRowsWithoutTrailingBorderRule() { - process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) - - val style = emittedStyle() - assertTrue( - style.contains( - """ - li + li { - border-top: 1px solid #d0d7de; - } - """.trimIndent() - ) - ) - assertFalse(style.contains("li:last-child")) - } - - @Test - fun emptyStateUsesExplicitLightAndDarkForegroundColors() { - process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) - - val style = emittedStyle() - val baseRule = - """ - .empty-dir { - display: flex; - align-items: flex-start; - gap: 0.5rem; - padding: 0.5rem; - color: #656d76; - font-style: italic; - } - """.trimIndent() - val darkModeMarker = "@media (prefers-color-scheme: dark)" - val darkRule = " .empty-dir {\n color: #8b949e;\n }" - - val baseRuleIndex = style.indexOf(baseRule) - val darkModeIndex = style.indexOf(darkModeMarker) - val darkRuleIndex = style.indexOf(darkRule, startIndex = darkModeIndex.coerceAtLeast(0)) - - assertTrue(baseRuleIndex >= 0) - assertFalse(style.contains("opacity:")) - assertTrue(darkModeIndex > baseRuleIndex) - assertTrue(darkRuleIndex > darkModeIndex) - } - - @Test - fun hoverAndKeyboardFocusUnderlineOnlyLinkText() { - process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) - - val style = emittedStyle() - val completeTargetRule = Regex("""a:hover, a:focus-visible \{([\s\S]*?)\}""") - .find(style) - ?.groupValues - ?.get(1) - assertNotNull(completeTargetRule) - assertFalse(completeTargetRule.contains("text-decoration")) - assertTrue(completeTargetRule.contains("outline: 2px solid #0969da;")) - assertTrue( - style.contains( - """ - a:hover span:last-child, a:focus-visible span:last-child { - text-decoration: underline; - } - """.trimIndent() - ) - ) - assertTrue(style.contains("@media (prefers-reduced-motion: reduce)")) - } - - @Test - fun authoredColorsMeetDocumentedContrastThresholds() { - assertTrue(contrastRatio("#656d76", "#ffffff") >= 4.5) - assertTrue(contrastRatio("#8b949e", "#0d1117") >= 4.5) - assertTrue(contrastRatio("#0969da", "#f6f8fa") >= 3.0) - assertTrue(contrastRatio("#58a6ff", "#161b22") >= 3.0) - } - - private fun generatedHtml(): String = - File(temporaryDirectory, "index.html").readText(Charsets.UTF_8) - - private fun emittedStyle(): String { - val style = Regex("""""") - .find(generatedHtml()) - ?.groupValues - ?.get(1) - return requireNotNull(style) { "Generated HTML must contain one inline style block" } - } - - private fun contrastRatio(foreground: String, background: String): Double { - val foregroundLuminance = relativeLuminance(foreground) - val backgroundLuminance = relativeLuminance(background) - val lighter = maxOf(foregroundLuminance, backgroundLuminance) - val darker = minOf(foregroundLuminance, backgroundLuminance) - return (lighter + 0.05) / (darker + 0.05) - } - - private fun relativeLuminance(hexColor: String): Double { - val channels = hexColor.removePrefix("#") - .chunked(2) - .map { it.toInt(16) / 255.0 } - .map { channel -> - if (channel <= 0.04045) { - channel / 12.92 - } else { - ((channel + 0.055) / 1.055).pow(2.4) - } - } - return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]) - } -} diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index 83739c9c..ee9cfac5 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -86,6 +86,16 @@ class MainTest { } } + @Test + fun testCssContainsMicroUxImprovements() { + go(tempDir.absolutePath, -1) + val indexFile = File(tempDir, "index.html") + assertTrue(indexFile.exists()) + val htmlContent = indexFile.readText() + assertTrue(htmlContent.contains("a:hover span:not(.icon), a:focus-visible span:not(.icon) {")) + assertTrue(htmlContent.contains("text-decoration: underline;")) + } + @Test fun testGoEmptyDir() { go(tempDir.absolutePath, -1) From 18fdcc18b3bd6677f577d353c2377a44fe69381d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:28:40 +0900 Subject: [PATCH 7/8] test(ui): restore generated-index accessibility contracts --- src/test/kotlin/html4tree/CspHashTest.kt | 46 ++++++++++++++++++++++++ src/test/kotlin/html4tree/MainTest.kt | 10 ------ 2 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 src/test/kotlin/html4tree/CspHashTest.kt diff --git a/src/test/kotlin/html4tree/CspHashTest.kt b/src/test/kotlin/html4tree/CspHashTest.kt new file mode 100644 index 00000000..388e3155 --- /dev/null +++ b/src/test/kotlin/html4tree/CspHashTest.kt @@ -0,0 +1,46 @@ +package html4tree + +import java.io.File +import java.nio.file.Files +import java.security.MessageDigest +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CspHashTest { + @Test + fun emittedStyleBytesMatchTheDeclaredCspHash() { + val directory = Files.createTempDirectory("html4tree-csp-").toFile() + + try { + process_dir(directory, setOf("index.html"), emptyArray()) + + val html = File(directory, "index.html").readText(Charsets.UTF_8) + val styleContent = Regex("""""") + .find(html) + ?.groupValues + ?.get(1) + val declaredHash = Regex("""style-src 'sha256-([^']+)'""") + .find(html) + ?.groupValues + ?.get(1) + + assertNotNull(styleContent, "Generated HTML must contain one inline style block") + assertNotNull(declaredHash, "Generated HTML must declare a SHA-256 style source") + assertEquals(styleContent.trim(), styleContent, "Hashed style bytes must not gain template padding") + + val actualHash = Base64.getEncoder().encodeToString( + MessageDigest.getInstance("SHA-256") + .digest(styleContent.toByteArray(Charsets.UTF_8)) + ) + assertEquals(declaredHash, actualHash) + assertTrue(styleContent.startsWith("body {")) + assertTrue(styleContent.endsWith("}")) + } finally { + directory.listFiles()?.forEach { it.delete() } + directory.delete() + } + } +} diff --git a/src/test/kotlin/html4tree/MainTest.kt b/src/test/kotlin/html4tree/MainTest.kt index ee9cfac5..83739c9c 100644 --- a/src/test/kotlin/html4tree/MainTest.kt +++ b/src/test/kotlin/html4tree/MainTest.kt @@ -86,16 +86,6 @@ class MainTest { } } - @Test - fun testCssContainsMicroUxImprovements() { - go(tempDir.absolutePath, -1) - val indexFile = File(tempDir, "index.html") - assertTrue(indexFile.exists()) - val htmlContent = indexFile.readText() - assertTrue(htmlContent.contains("a:hover span:not(.icon), a:focus-visible span:not(.icon) {")) - assertTrue(htmlContent.contains("text-decoration: underline;")) - } - @Test fun testGoEmptyDir() { go(tempDir.absolutePath, -1) From 847141d51849ff796d037d9987602e8d5d406582 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 6 Aug 2026 10:30:54 +0900 Subject: [PATCH 8/8] fix(ui): restore generated-index readability implementation --- .github/dependabot.yml | 15 ++ .github/workflows/ci.yml | 9 + .jules/palette.md | 4 - .jules/sentinel.md | 5 + CHANGELOG.md | 32 ++++ SECURITY.md | 11 ++ .../csp-inline-style-byte-identity.md | 60 ++++++ docs/doctoring/generated-index-readability.md | 51 +++++ gradle/wrapper/gradle-wrapper.properties | 1 + src/main/kotlin/html4tree/main.kt | 164 ++++++++-------- .../GeneratedIndexReadabilityTest.kt | 181 ++++++++++++++++++ 11 files changed, 449 insertions(+), 84 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 CHANGELOG.md create mode 100644 SECURITY.md create mode 100644 docs/doctoring/csp-inline-style-byte-identity.md create mode 100644 docs/doctoring/generated-index-readability.md create mode 100644 src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..bc3f55be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: / + target-branch: master + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + target-branch: master + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b46b5e1a..b3a62925 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,15 @@ jobs: run: | echo "JAVA_HOME=$JAVA_HOME_11_X64" >> "$GITHUB_ENV" echo "$JAVA_HOME_11_X64/bin" >> "$GITHUB_PATH" + - name: Verify Gradle wrapper integrity + shell: bash + run: | + expected_sha256="76b12da7f4a7cdd025e5996811a2e49bf5df0fb62d72554ab555c0e434b63aae" + actual_sha256="$(sha256sum gradle/wrapper/gradle-wrapper.jar | awk '{print $1}')" + if [[ "$actual_sha256" != "$expected_sha256" ]]; then + echo "::error file=gradle/wrapper/gradle-wrapper.jar::Gradle wrapper checksum mismatch: expected $expected_sha256, got $actual_sha256" + exit 1 + fi - name: Build and test (includes jacoco coverage verification) run: ./gradlew build --no-daemon - name: Report coverage gaps on failure diff --git a/.jules/palette.md b/.jules/palette.md index 3622a477..7b223901 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -52,7 +52,3 @@ ## 2024-07-13 - 빈 디렉토리 상태의 접근성(Accessibility) 개선 **Learning:** 정적 파일 서버의 빈 디렉토리 상태는 스크린 리더 사용자에게 컨텐츠 누락으로 오해받을 수 있으며, 시각적으로도 일반 리스트 아이템과 정렬이 맞지 않는 문제가 있었습니다. **Action:** 빈 상태를 나타내는 요소에 `role="status"`를 추가하여 스크린 리더가 명확하게 인지할 수 있도록 하고, 아이콘과 flex 레이아웃을 통해 다른 리스트 아이템과 일관된 시각적 흐름을 제공하도록 합니다. - -## 2024-08-04 - 아이콘과 텍스트가 함께 있는 링크의 Hover 상태 시각적 개선 -**학습:** 아이콘과 텍스트를 모두 포함하는 `` 태그 전체에 `text-decoration: underline`을 적용하면 장식용 아이콘 아래에도 밑줄이 표시되어 시각적으로 깔끔하지 못한 UI(시각적 계층 불일치)가 발생합니다. -**조치:** `:hover` 및 `:focus-visible` 상태에서 전체 `` 블록에 밑줄을 적용하는 대신, 텍스트가 포함된 내부 `span:not(.icon)`에만 선택적으로 `text-decoration: underline`을 적용하여 아이콘 아래의 불필요한 밑줄을 제거하십시오. diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 6ecf72f1..cdf88010 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -83,3 +83,8 @@ **Vulnerability:** 정적 HTML 생성 도구에서 매번 다른 Nonce를 동적으로 생성하여 CSP에 적용하는 것은, 캐싱 효율을 저하시킬 뿐만 아니라 정적 배포 환경(예: GitHub Pages 등)에서 올바른 보안 정책 수립을 방해할 수 있는 안티 패턴입니다. **Learning:** 정적으로 고정된 인라인 스타일이나 스크립트에는 난수화된 Nonce보다 콘텐츠 자체의 해시(SHA-256 등)를 사용하는 것이 안전하고 일관된 방식임을 배웠습니다. **Prevention:** 자동 생성되는 정적 HTML의 콘텐츠 보안 정책(CSP)에는 `style-src 'sha256-'` 방식을 적용하고, ``와 같이 공백 없이 주입하여 해시가 완벽하게 일치하도록 해야 합니다. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ca420843 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +All notable changes to this project are documented in this file. + +## [Unreleased] + +### Changed + +- Improve generated directory-index readability with adjacent-row separators, + explicit light and dark empty-state text colors, and text-only hover/focus + underlining while retaining the full interactive target's focus outline. + +### Fixed + +- Generate the inline-style Content Security Policy SHA-256 source expression + from the exact normalized UTF-8 stylesheet bytes emitted into each generated + `index.html` file, preventing template whitespace from invalidating the policy. + +### Tests + +- Add a real generated-file regression test that independently recomputes the + declared style hash from the emitted ` - """ - val index_top = """ @@ -329,11 +333,11 @@ ${cssContent} - + ${curr_dir.getName().escapeHtml()} - ${css} +
    diff --git a/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt new file mode 100644 index 00000000..d78732c0 --- /dev/null +++ b/src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt @@ -0,0 +1,181 @@ +package html4tree + +import org.junit.After +import org.junit.Before +import org.junit.Test +import java.io.File +import java.nio.file.Files +import kotlin.math.pow +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Product-level regressions for the CSS and markup emitted into a generated + * directory index. + * + * These tests inspect the real `index.html` output rather than an independent + * stylesheet fixture, so a template or CSP-byte regression cannot be hidden by + * a test-only copy of the CSS. + */ +class GeneratedIndexReadabilityTest { + private lateinit var temporaryDirectory: File + + @Before + fun createTemporaryDirectory() { + temporaryDirectory = Files.createTempDirectory("html4tree-readability-").toFile() + } + + @After + fun removeTemporaryDirectory() { + temporaryDirectory.deleteRecursively() + } + + @Test + fun generatedRowsPreserveFirstMiddleAndLastOrder() { + val firstFile = File(temporaryDirectory, "alpha.txt").apply { writeText("alpha") } + val middleFile = File(temporaryDirectory, "middle.txt").apply { writeText("middle") } + val lastFile = File(temporaryDirectory, "zulu.txt").apply { writeText("zulu") } + + process_dir( + temporaryDirectory, + setOf("index.html"), + arrayOf(lastFile, firstFile, middleFile) + ) + + val generatedHtml = generatedHtml() + val parentIndex = generatedHtml.indexOf("..") + val firstIndex = generatedHtml.indexOf("alpha.txt") + val middleIndex = generatedHtml.indexOf("middle.txt") + val lastIndex = generatedHtml.indexOf("zulu.txt") + + assertTrue(parentIndex >= 0) + assertTrue(parentIndex < firstIndex) + assertTrue(firstIndex < middleIndex) + assertTrue(middleIndex < lastIndex) + assertFalse(generatedHtml.contains("이 디렉토리는 비어 있습니다.")) + } + + @Test + fun emptyDirectoryRetainsOneSemanticStatusRow() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val generatedHtml = generatedHtml() + val expectedEmptyRow = + """
  • 이 디렉토리는 비어 있습니다.
  • """ + + assertTrue(generatedHtml.contains(expectedEmptyRow)) + assertTrue(generatedHtml.indexOf(expectedEmptyRow) == generatedHtml.lastIndexOf(expectedEmptyRow)) + } + + @Test + fun stylesheetSeparatesAdjacentRowsWithoutTrailingBorderRule() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + assertTrue( + style.contains( + """ + li + li { + border-top: 1px solid #d0d7de; + } + """.trimIndent() + ) + ) + assertFalse(style.contains("li:last-child")) + } + + @Test + fun emptyStateUsesExplicitLightAndDarkForegroundColors() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + val baseRule = + """ + .empty-dir { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.5rem; + color: #656d76; + font-style: italic; + } + """.trimIndent() + val darkModeMarker = "@media (prefers-color-scheme: dark)" + val darkRule = " .empty-dir {\n color: #8b949e;\n }" + + val baseRuleIndex = style.indexOf(baseRule) + val darkModeIndex = style.indexOf(darkModeMarker) + val darkRuleIndex = style.indexOf(darkRule, startIndex = darkModeIndex.coerceAtLeast(0)) + + assertTrue(baseRuleIndex >= 0) + assertFalse(style.contains("opacity:")) + assertTrue(darkModeIndex > baseRuleIndex) + assertTrue(darkRuleIndex > darkModeIndex) + } + + @Test + fun hoverAndKeyboardFocusUnderlineOnlyLinkText() { + process_dir(temporaryDirectory, setOf("index.html"), emptyArray()) + + val style = emittedStyle() + val completeTargetRule = Regex("""a:hover, a:focus-visible \{([\s\S]*?)\}""") + .find(style) + ?.groupValues + ?.get(1) + assertNotNull(completeTargetRule) + assertFalse(completeTargetRule.contains("text-decoration")) + assertTrue(completeTargetRule.contains("outline: 2px solid #0969da;")) + assertTrue( + style.contains( + """ + a:hover span:last-child, a:focus-visible span:last-child { + text-decoration: underline; + } + """.trimIndent() + ) + ) + assertTrue(style.contains("@media (prefers-reduced-motion: reduce)")) + } + + @Test + fun authoredColorsMeetDocumentedContrastThresholds() { + assertTrue(contrastRatio("#656d76", "#ffffff") >= 4.5) + assertTrue(contrastRatio("#8b949e", "#0d1117") >= 4.5) + assertTrue(contrastRatio("#0969da", "#f6f8fa") >= 3.0) + assertTrue(contrastRatio("#58a6ff", "#161b22") >= 3.0) + } + + private fun generatedHtml(): String = + File(temporaryDirectory, "index.html").readText(Charsets.UTF_8) + + private fun emittedStyle(): String { + val style = Regex("""""") + .find(generatedHtml()) + ?.groupValues + ?.get(1) + return requireNotNull(style) { "Generated HTML must contain one inline style block" } + } + + private fun contrastRatio(foreground: String, background: String): Double { + val foregroundLuminance = relativeLuminance(foreground) + val backgroundLuminance = relativeLuminance(background) + val lighter = maxOf(foregroundLuminance, backgroundLuminance) + val darker = minOf(foregroundLuminance, backgroundLuminance) + return (lighter + 0.05) / (darker + 0.05) + } + + private fun relativeLuminance(hexColor: String): Double { + val channels = hexColor.removePrefix("#") + .chunked(2) + .map { it.toInt(16) / 255.0 } + .map { channel -> + if (channel <= 0.04045) { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).pow(2.4) + } + } + return (0.2126 * channels[0]) + (0.7152 * channels[1]) + (0.0722 * channels[2]) + } +}