Skip to content
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ 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
Expand All @@ -14,8 +20,13 @@ All notable changes to this project are documented in this file.

- Add a real generated-file regression test that independently recomputes the
declared style hash from the emitted `<style>` text.
- Add generated-page regressions for row ordering, empty-state semantics, CSS
cascade ordering, reduced-motion retention, text-only decoration, and numeric
text/focus contrast thresholds.

### Documentation

- Record the CSP byte-identity decision, threat boundary, verification contract,
and current W3C Working Draft reference in `docs/doctoring`.
- Record the generated-index readability decision, WCAG 2.2 engineering basis,
contrast calculations, scope boundaries, and verification contract.
51 changes: 51 additions & 0 deletions docs/doctoring/generated-index-readability.md
Original file line number Diff line number Diff line change
@@ -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 `<style>` bytes. Any CSS change must continue to preserve the single-source `CSS_CONTENT` and `STYLE_HASH` byte-identity contract rather than weakening CSP with `unsafe-inline`, a broader source expression, or a duplicated stylesheet fixture.

## References

World Wide Web Consortium. (2024, December 12). *Web Content Accessibility Guidelines (WCAG) 2.2* (W3C Recommendation). https://www.w3.org/TR/WCAG22/

World Wide Web Consortium. (2026, February 11). *Understanding WCAG 2.2*. Web Accessibility Initiative. https://www.w3.org/WAI/WCAG22/Understanding/

World Wide Web Consortium. (2026, March 9). *Understanding Success Criterion 2.4.13: Focus appearance*. Web Accessibility Initiative. https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html
29 changes: 20 additions & 9 deletions src/main/kotlin/html4tree/main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()

Expand Down
181 changes: 181 additions & 0 deletions src/test/kotlin/html4tree/GeneratedIndexReadabilityTest.kt
Original file line number Diff line number Diff line change
@@ -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("<span>..</span>")
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 =
"""<li><div class="empty-dir" role="status"><span class="icon" aria-hidden="true">&#8505;</span> <span>이 디렉토리는 비어 있습니다.</span></div></li>"""

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("""<style>([\s\S]*?)</style>""")
.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])
}
}
Loading