From f6adb764cd6d4383c7dfd4710af3be0716a91230 Mon Sep 17 00:00:00 2001 From: leogdion Date: Tue, 14 Jul 2026 21:19:05 -0400 Subject: [PATCH 01/22] TailwindKit: type-safe Tailwind v4 class builder (#149) --- .devcontainer/devcontainer-lock.json | 14 + .devcontainer/devcontainer.json | 32 +++ .github/dependabot.yml | 11 + .github/workflows/TailwindKit.yml | 270 ++++++++++++++++++ .github/workflows/check-unsafe-flags.yml | 39 +++ .github/workflows/claude-code-review.yml | 44 +++ .github/workflows/claude.yml | 49 ++++ .github/workflows/cleanup-caches.yml | 29 ++ .github/workflows/swift-source-compat.yml | 28 ++ .gitignore | 144 ++++++++++ .mise.toml | 16 ++ .periphery.yml | 3 + .swift-format | 70 +++++ .swift-version | 1 + .swiftlint.yml | 149 ++++++++++ CLAUDE.md | 67 +++++ Package.swift | 30 ++ README.md | 69 +++++ Scripts/header.sh | 104 +++++++ Scripts/lint.sh | 112 ++++++++ Sources/TailwindKit/Node+Tailwind.swift | 56 ++++ Sources/TailwindKit/TailwindStyle+Bare.swift | 95 ++++++ .../TailwindKit/TailwindStyle+Static.swift | 189 ++++++++++++ .../TailwindStyle+StaticBare.swift | 95 ++++++ .../TailwindKit/TailwindStyle+Tokens.swift | 209 ++++++++++++++ .../TailwindKit/TailwindStyle+Utilities.swift | 190 ++++++++++++ Sources/TailwindKit/TailwindStyle.swift | 94 ++++++ .../TailwindKitTests/TailwindStyleTests.swift | 141 +++++++++ codecov.yml | 2 + 29 files changed, 2352 insertions(+) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/TailwindKit.yml create mode 100644 .github/workflows/check-unsafe-flags.yml create mode 100644 .github/workflows/claude-code-review.yml create mode 100644 .github/workflows/claude.yml create mode 100644 .github/workflows/cleanup-caches.yml create mode 100644 .github/workflows/swift-source-compat.yml create mode 100644 .gitignore create mode 100644 .mise.toml create mode 100644 .periphery.yml create mode 100644 .swift-format create mode 100644 .swift-version create mode 100644 .swiftlint.yml create mode 100644 CLAUDE.md create mode 100644 Package.swift create mode 100644 README.md create mode 100755 Scripts/header.sh create mode 100755 Scripts/lint.sh create mode 100644 Sources/TailwindKit/Node+Tailwind.swift create mode 100644 Sources/TailwindKit/TailwindStyle+Bare.swift create mode 100644 Sources/TailwindKit/TailwindStyle+Static.swift create mode 100644 Sources/TailwindKit/TailwindStyle+StaticBare.swift create mode 100644 Sources/TailwindKit/TailwindStyle+Tokens.swift create mode 100644 Sources/TailwindKit/TailwindStyle+Utilities.swift create mode 100644 Sources/TailwindKit/TailwindStyle.swift create mode 100644 Tests/TailwindKitTests/TailwindStyleTests.swift create mode 100644 codecov.yml diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..8fbef47 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "version": "2.5.9", + "resolved": "ghcr.io/devcontainers/features/common-utils@sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a", + "integrity": "sha256:cb0c4d3c276f157eed17935747e364178d75fee17f55c4e129966f64633deb3a" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "1.3.5", + "resolved": "ghcr.io/devcontainers/features/git@sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251", + "integrity": "sha256:27905dc196c01f77d6ba8709cb82eeaf330b3b108772e2f02d1cd0d826de1251" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..7586930 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Swift 6.4", + "image": "swiftlang/swift:nightly-6.4.x-noble", + "features": { + "ghcr.io/devcontainers/features/common-utils:2": { + "installZsh": "false", + "username": "vscode", + "upgradePackages": "false" + }, + "ghcr.io/devcontainers/features/git:1": { + "version": "os-provided", + "ppa": "false" + } + }, + "postStartCommand": "git config --global --add safe.directory ${containerWorkspaceFolder}", + "runArgs": [ + "--cap-add=SYS_PTRACE", + "--security-opt", + "seccomp=unconfined" + ], + "customizations": { + "vscode": { + "settings": { + "lldb.library": "/usr/lib/liblldb.so" + }, + "extensions": [ + "sswg.swift-lang" + ] + } + }, + "remoteUser": "root" +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..ae22d88 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "swift" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "weekly" diff --git a/.github/workflows/TailwindKit.yml b/.github/workflows/TailwindKit.yml new file mode 100644 index 0000000..5feab6f --- /dev/null +++ b/.github/workflows/TailwindKit.yml @@ -0,0 +1,270 @@ +name: TailwindKit + +# Standalone CI for this BrightDigit Swift package on Swift 6.4 (the BrightDigit +# CI template). These five workflows are kept byte-identical except the name above; +# the only per-package difference is externalized to the ENABLE_WASM repo variable. +# Linux + wasm run in the nightly-6.4 container; macOS + the Apple-platform suite +# run on the self-hosted runner with /Applications/Xcode-beta.app (Xcode 27 / +# Swift 6.4); Windows on a swift.org nightly snapshot; Android via a swift.org SDK +# bundle. The wasm step is gated on ENABLE_WASM — set it to 'false' where a +# dependency can't build for wasm (e.g. Yams). +on: + push: + branches: [main] + tags: ['v*.*.*'] + paths-ignore: + - '**.md' + - 'LICENSE' + pull_request: + paths-ignore: + - '**.md' + - 'LICENSE' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + # Ubuntu variants from a matrix (configure → ubuntu-type): standard ('') always; + # wasm + wasm-embedded when ENABLE_WASM != 'false'. Both wasm variants share the + # same nightly-6.4.x SDK bundle. OpenAPI packages guard URLSession behind + # #if !os(WASI) so the wasm/embedded legs build. + strategy: + fail-fast: false + matrix: + type: ${{ fromJSON(needs.configure.outputs.ubuntu-type) }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + type: ${{ matrix.type }} + # wasm / wasm-embedded are build-only: WasmKit doesn't mount the Foundation + # resource bundles the tests load, and embedded hits OpenAPIRuntime keypath + # limits at runtime. The standard ('') leg runs the full suite + coverage. + build-only: ${{ matrix.type != '' }} + wasm-sdk-url: https://download.swift.org/swift-6.4.x-branch/wasm-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_wasm.artifactbundle.tar.gz + wasm-sdk-checksum: c014a0162de6eeeef4f07904ce096ccee5405c1478a12c99a3a9750bd42d7702 + wasm-swift-flags: >- + -Xcc -D_WASI_EMULATED_SIGNAL -Xcc -D_WASI_EMULATED_MMAN + -Xlinker -lwasi-emulated-signal -Xlinker -lwasi-emulated-mman + -Xlinker -lwasi-emulated-getpid + -Xlinker --initial-memory=536870912 -Xlinker --max-memory=536870912 + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if ! command -v curl &> /dev/null; then + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + fi + # brightdigit fork of swift-coverage-action: upstream @v5 can't pair the + # profdata with the test binary under Swift 6.4's swiftbuild layout + # (.so + -test-runner, no .xctest) — issue #92. Pinned by SHA. + - uses: brightdigit/swift-coverage-action@2f8538f723b99ab2406ac3a0e5b3355a9de4cf6c + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + search-paths: .build + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: noble,swift-6.4 + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + run-windows: ${{ steps.check.outputs.windows }} + ubuntu-type: ${{ steps.check.outputs.ubuntu-type }} + steps: + - uses: actions/checkout@v6 + - id: check + name: Determine matrix scope + run: | + # Three tiers: small set (build-ubuntu[+wasm]/build-macos/lint, always) < + # full-matrix (macOS-platforms/android) < +Windows. full-matrix runs on + # main / semver / dispatch / PRs into main or semver. Windows is the most + # expensive leg, so run-windows is the same MINUS PRs into semver branches. + FULL=false; WIN=false + REF="${{ github.ref }}"; EVENT="${{ github.event_name }}"; BASE_REF="${{ github.base_ref }}" + if [[ "$REF" == "refs/heads/main" ]]; then FULL=true; WIN=true + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "workflow_dispatch" ]]; then FULL=true; WIN=true + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" ]]; then FULL=true; WIN=true + elif [[ "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then FULL=true + fi + fi + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "windows=$WIN" >> "$GITHUB_OUTPUT" + # Ubuntu build variants: standard ('') always; wasm + wasm-embedded + # unless the package disables wasm via ENABLE_WASM (e.g. Yams). + if [[ "${{ vars.ENABLE_WASM }}" != "false" ]]; then + echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" + fi + + # macOS on Swift 6.4 via the self-hosted runner with /Applications/Xcode-beta.app. + build-macos: + name: Build on macOS + runs-on: [self-hosted, macOS] + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + xcode: "/Applications/Xcode-beta.app" + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + flags: spm,macos + token: ${{ secrets.CODECOV_TOKEN }} + + # Windows nightly 6.4 via a swift.org development snapshot (compnerd/gha-setup-swift + # under swift-build), on hosted runners. Own tier (run-windows): the most + # expensive leg, so it runs on main / semver / dispatch / PRs into main, but NOT + # PRs into semver branches. Bump the snapshot id periodically (swift.org GCs them). + build-windows: + name: Build on Windows + needs: configure + if: ${{ needs.configure.outputs.run-windows == 'true' }} + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: [windows-2022, windows-2025] + swift: + - version: swift-6.4.x-branch + build: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-01-a + steps: + - uses: actions/checkout@v6 + - uses: brightdigit/swift-build@v1 + id: build + with: + windows-swift-version: ${{ matrix.swift.version }} + windows-swift-build: ${{ matrix.swift.build }} + + # Full Apple-platform suite (iOS/watchOS/tvOS) on Swift 6.4, on the self-hosted + # runner with /Applications/Xcode-beta.app (Xcode 27), using the 27.0 simulator + # runtimes installed there. Blocking leg; simulator runs on the nightly + # toolchain are the most fragile leg. Bump deviceName/osVersion as Xcode-beta moves. + # The watchOS leg is gated on the ENABLE_WATCHOS repo variable (set 'false' to skip it + # where the watchOS-27 SDK rejects deps inferring 8.0 — SwiftPM #10188; see + # brightdigit.com#119). iOS/tvOS always run. + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: [self-hosted, macOS] + strategy: + fail-fast: false + matrix: + include: + - { type: ios, deviceName: "iPhone 17 Pro", osVersion: "27.0" } + - { type: watchos, deviceName: "Apple Watch Ultra 3 (49mm)", osVersion: "27.0" } + - { type: tvos, deviceName: "Apple TV 4K (3rd generation)", osVersion: "27.0" } + steps: + - uses: actions/checkout@v6 + - name: Build and Test + id: build + # watchOS disabled where ENABLE_WATCHOS=false (SwiftPM #10188, watchOS-27 + # deployment-target clamp); re-enable per brightdigit.com#119. + if: ${{ !(matrix.type == 'watchos' && vars.ENABLE_WATCHOS == 'false') }} + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: "/Applications/Xcode-beta.app" + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + - name: Process coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + with: + search-paths: .build + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v7 + with: + fail_ci_if_error: false + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ format('{0}{1}', matrix.type, matrix.osVersion) }} + + # Android on nightly 6.4 via a swift.org swift-6.4.x-branch snapshot SDK bundle + # (custom-sdk-url path through swift-build → skiptools/swift-android-action). + # Blocking leg; build-only (android-run-tests: false). + # Bump the snapshot id periodically. + build-android: + name: Build on Android + needs: configure + if: ${{ needs.configure.outputs.full-matrix == 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + android-api-level: [34] + steps: + - uses: actions/checkout@v6 + - name: Free disk space + uses: jlumbroso/free-disk-space@v1.3.1 + with: + tool-cache: false + android: false + dotnet: true + haskell: true + large-packages: true + docker-images: true + swap-storage: true + - uses: brightdigit/swift-build@v1 + with: + type: android + android-swift-version: 6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a + android-sdk-url: https://download.swift.org/swift-6.4.x-branch/android-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android.artifactbundle.tar.gz + android-sdk-id: swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-06-15-a_android + android-api-level: ${{ matrix.android-api-level }} + android-run-tests: false + + lint: + name: Linting + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-windows, build-macos-platforms] + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Install curl (required by mise-action) + run: | + apt-get update -q + apt-get install -y --no-install-recommends curl ca-certificates + - uses: actions/checkout@v6 + - uses: jdx/mise-action@v4 + env: + MISE_HTTP_TIMEOUT: 300s + MISE_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + cache: true + - name: Lint + run: ./Scripts/lint.sh + env: + LINT_MODE: STRICT diff --git a/.github/workflows/check-unsafe-flags.yml b/.github/workflows/check-unsafe-flags.yml new file mode 100644 index 0000000..3af5e56 --- /dev/null +++ b/.github/workflows/check-unsafe-flags.yml @@ -0,0 +1,39 @@ +name: Check for unsafeFlags + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +jobs: + dump-package-check: + name: Dump Swift package (authoritative) and scan JSON + runs-on: ubuntu-latest + container: + image: swiftlang/swift:nightly-6.4.x-noble + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install jq + run: | + apt-get update && apt-get install -y jq + + - name: Dump package JSON and check for unsafeFlags + shell: bash + run: | + set -euo pipefail + # Compute unsafeFlags array directly from the dump (don't store the full dump variable) + unsafe_flags=$(swift package dump-package | jq -c '[.. | objects | .unsafeFlags? // empty]') + # Check array length to decide failure + if [ "$(echo "$unsafe_flags" | jq 'length')" -gt 0 ]; then + echo "ERROR: unsafeFlags found in resolved package JSON:" + echo "$unsafe_flags" | jq '.' || true + echo "--- resolved package dump (first 200 lines) ---" + # Print a sample of the authoritative dump (re-run dump-package for the sample) + swift package dump-package | sed -n '1,200p' || true + exit 1 + else + echo "No unsafeFlags in resolved package JSON." + fi diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..b5e8cfd --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..9471a05 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,49 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/cleanup-caches.yml b/.github/workflows/cleanup-caches.yml new file mode 100644 index 0000000..f0124e2 --- /dev/null +++ b/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/.github/workflows/swift-source-compat.yml b/.github/workflows/swift-source-compat.yml new file mode 100644 index 0000000..d4d03bb --- /dev/null +++ b/.github/workflows/swift-source-compat.yml @@ -0,0 +1,28 @@ +name: Swift Source Compatibility + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + swift-source-compat-suite: + name: Test Swift ${{ matrix.container }} For Source Compatibility Suite + runs-on: ubuntu-latest + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + + strategy: + fail-fast: false + matrix: + container: + - swiftlang/swift:nightly-6.4.x-noble + + container: ${{ matrix.container }} + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Test Swift 6.x For Source Compatibility + run: swift build --disable-sandbox --verbose --configuration release diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8704f86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,swift,SwiftPackageManager,Xcode +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,swift,SwiftPackageManager,Xcode + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### Swift ### +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) +*.xcscmblueprint +*.xccheckout + +## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) +build/ +DerivedData/ +*.moved-aside + +# Xcode +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# Package.resolved +*.xcodeproj +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +.swiftpm + +.build/ + +# CocoaPods +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# Pods/ +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# Accio dependency management +Dependencies/ +.accio/ + +# fastlane +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# Code Injection +# After new code Injection tools there's a generated folder /iOSInjectionProject +# https://github.com/johnno1962/injectionforxcode + +iOSInjectionProject/ + +### SwiftPackageManager ### +# Packages +xcuserdata +*.xcodeproj + + +### Xcode ### +# Xcode +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + + + + +## Gcc Patch +/*.gcno + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/macos,swift,SwiftPackageManager,Xcode + +.mint diff --git a/.mise.toml b/.mise.toml new file mode 100644 index 0000000..87bc056 --- /dev/null +++ b/.mise.toml @@ -0,0 +1,16 @@ +[settings] +# Enable experimental features (required for the spm backend) +experimental = true + +[tools] +# Swift development tools (managed via mise) +# 604.x aligns swift-format (and its swift-syntax) with the Swift 6.4 toolchain. +# Only one 604 tag exists so far; bump when a newer 604 prerelease/release lands. +"spm:swiftlang/swift-format" = "604.0.0-prerelease-2025-12-17" +"aqua:realm/SwiftLint" = "0.63.2" +"spm:peripheryapp/periphery" = "3.7.4" + +[tasks] +swift-format = "swift-format" +swiftlint = "swiftlint" +periphery = "periphery" diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..e9b018d --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +targets: +- TailwindKit diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..257f555 --- /dev/null +++ b/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..944c7e2 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.4.x-snapshot diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..4ccdb85 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,149 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + # - fallthrough + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping +# - function_default_parameter_at_end + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool +# - trailing_closure + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - .swiftpm + - "*/.swiftpm" + - "*/.build" + - Mint + - Examples + - Packages + - Package.swift +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..48d3958 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +TailwindKit is a small, dependency-light Swift module that emits **type-safe +[Tailwind CSS v4](https://tailwindcss.com) utility class strings** for +brightdigit.com's component markup. Swift 6.4 tools version, Swift 6 language +mode; targets macOS 13+ and Linux. Tailwind **v4 only**. + +## Commands + +```bash +swift test # run the (offline, Plot-independent) tests +swift build --build-tests # compile + +# Single test: filter by suite/method +swift test --filter TailwindStyleTests/canonicalChain + +# Lint/format/check (installs pinned tools via mise, formats in place locally, builds tests, runs periphery) +./Scripts/lint.sh +LINT_MODE=STRICT ./Scripts/lint.sh # CI-style strict mode (no auto-fix; lint + build only when CI is set) + +mise install # install pinned tools (swift-format, swiftlint, periphery) +``` + +Tooling (swift-format, SwiftLint, periphery) is pinned in `.mise.toml` and run +through `mise exec --`. `Scripts/lint.sh` auto-formats and rewrites file headers +only when `CI` is unset; in CI it lints and builds without mutating files. + +## Architecture — the one design decision + +`TailwindStyle` is an immutable value builder that is **Plot-independent**. The +core builder and its utility surface live in Plot-free files +(`TailwindStyle.swift`, `TailwindStyle+Tokens.swift`, +`TailwindStyle+Utilities.swift`, `TailwindStyle+Static.swift`). The **only** file +that imports Plot is `Node+Tailwind.swift`, which adds the single sugar +`.tailwind(_ style:)` → `.class(style.rendered)` on `Node`/`Attribute`. + +- Every fluent member returns a new `TailwindStyle`. Bare utilities are computed + properties (`.flex`, `.gap`); parameterized ones are methods (`.gap(4)`, + `.bg(.blue, .s500)`). Static mirrors in `TailwindStyle+Static.swift` let a + chain start with a leading dot. +- To satisfy SwiftLint's `type_contents_order`, each of the utilities/static + files is split into a **bare-utilities (properties)** extension and a + **parameterized-utilities (methods)** extension. +- The modeled surface is a **closed** set of enums, grown component-driven for + consumers (issue #67). The escape hatch for unmodeled classes is Plot's + existing `.class("…")`; TailwindKit takes no raw strings. +- Shades are enum cases `.s50`…`.s950` (Swift disallows the `.500` spelling and + leading underscores), e.g. `.bg(.blue, .s500)`. + +## Tests + +Tests are offline and **Plot-independent**: they assert `.rendered` string +equality only (e.g. `TW.flex.gap(4).rendered == "flex gap-4"`), so nothing in +the test target imports Plot. Uses swift-testing (`@Suite`/`@Test`/`#expect`), +not XCTest. + +## Conventions + +- The MIT license header on every hand-written Swift file is managed by + `Scripts/header.sh` (invoked from `lint.sh` locally). Don't hand-maintain + headers. +- Strict concurrency is complete (Swift 6 mode). `@unchecked Sendable` is banned + by a custom SwiftLint rule — fix Sendability properly. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..543a7d9 --- /dev/null +++ b/Package.swift @@ -0,0 +1,30 @@ +// swift-tools-version:6.4 +// swiftlint:disable explicit_acl explicit_top_level_acl + +import PackageDescription + +let package = Package( + name: "TailwindKit", + platforms: [.macOS(.v13)], + products: [ + .library( + name: "TailwindKit", + targets: ["TailwindKit"] + ) + ], + dependencies: [ + .package(path: "../../Publish/Plot") + ], + targets: [ + .target( + name: "TailwindKit", + dependencies: [ + .product(name: "Plot", package: "Plot") + ] + ), + .testTarget( + name: "TailwindKitTests", + dependencies: ["TailwindKit"] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..65d458e --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# TailwindKit + +A tiny, type-safe Swift builder for [Tailwind CSS v4](https://tailwindcss.com) +utility class strings, used by brightdigit.com to author component markup with +compile-time-checked CSS classes instead of stringly-typed `class="…"`. + +TailwindKit is **Tailwind v4 only**. The v2 → v4 site migration and the +component migration that consumes this package are tracked separately (issues +#145 and #67). + +## Usage + +The core is `TailwindStyle` — an immutable value builder where every member +returns a new `TailwindStyle`. Bare utilities are computed properties; +parameterized utilities are methods. Render the accumulated tokens with +`.rendered`: + +```swift +import TailwindKit + +TW.flex.items(.center).gap(4).bg(.blue, .s500).rendered +// "flex items-center gap-4 bg-blue-500" +``` + +`TW` is a convenience alias for `TailwindStyle`, so a chain can start with the +type name or a leading dot. + +### With Plot + +The single Plot bridge is `.tailwind(_:)`, which expands to Plot's +`.class(style.rendered)` on any `Node`/`Attribute` in an HTML context: + +```swift +import Plot +import TailwindKit + +Node.div(.tailwind(.flex.items(.center).gap(4)), .text("Hi")) +//
Hi
+``` + +The builder itself never imports Plot — only `Node+Tailwind.swift` does — so +`TailwindStyle` stays usable without an HTML library, and its tests assert on +`.rendered` strings alone. + +### Responsive & state variants + +Variants take a nested style and prefix every one of its tokens; prefixes stack: + +```swift +TW.block.lg(.hidden).rendered // "block lg:hidden" +TW.md(.hover(.bg(.blue, .s700))).rendered // "md:hover:bg-blue-700" +``` + +## Scope + +The modeled utility surface is intentionally **closed** — a set of Swift enums +and methods — and grows component-driven as consumers (issue #67) need new +classes. For any class not yet modeled, the escape hatch is Plot's existing +`.class("…")`; TailwindKit itself never accepts raw strings. + +## Testing + +```bash +swift test +``` + +Tests (`Tests/TailwindKitTests`) are Plot-independent and assert `.rendered` +string equality, e.g. `TW.flex.gap(4).rendered == "flex gap-4"`. They use +swift-testing (`@Suite`/`@Test`/`#expect`). diff --git a/Scripts/header.sh b/Scripts/header.sh new file mode 100755 index 0000000..c571c18 --- /dev/null +++ b/Scripts/header.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template +header_template="// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the \"Software\"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +//" + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files in the Generated directory + if [[ "$file" == *"/Generated/"* ]]; then + echo "Skipping $file (generated file)" + continue + fi + + # Check if the first line is the swift-format-ignore indicator + first_line=$(head -n 1 "$file") + if [[ "$first_line" == "// swift-format-ignore-file" ]]; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + filename=$(basename "$file") + header=$(printf "$header_template" "$filename" "$package" "$creator" "$year" "$company") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Scripts/lint.sh b/Scripts/lint.sh new file mode 100755 index 0000000..377a4d7 --- /dev/null +++ b/Scripts/lint.sh @@ -0,0 +1,112 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + "$@" || ERRORS=$((ERRORS + 1)) +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Detect if mise is available +# Check common installation paths for mise +MISE_PATHS=( + "/opt/homebrew/bin/mise" + "/usr/local/bin/mise" + "$HOME/.local/bin/mise" +) + +MISE_BIN="" +for mise_path in "${MISE_PATHS[@]}"; do + if [ -x "$mise_path" ]; then + MISE_BIN="$mise_path" + break + fi +done + +# Fallback to PATH lookup +if [ -z "$MISE_BIN" ] && command -v mise &> /dev/null; then + MISE_BIN="mise" +fi + +if [ -n "$MISE_BIN" ]; then + TOOL_CMD="$MISE_BIN exec --" +else + echo "Error: mise is not installed" + echo "Install mise: https://mise.jdx.dev/getting-started.html" + echo "Checked paths: ${MISE_PATHS[*]}" + exit 1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_LINT_OPTIONS="--strict" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_LINT_OPTIONS="" + SWIFTLINT_OPTIONS="" +fi + +pushd $PACKAGE_DIR + +# swift-format has no path-exclude config, so enumerate the hand-written Swift +# files only — pruning the committed swift-openapi-generator output under +# Sources/ButtondownKit/Generated, which is never linted or formatted. +# SwiftLint excludes that directory via .swiftlint.yml. Built portably (no +# `mapfile`, which is unavailable on the macOS-default bash 3.2). +SWIFT_FILES=() +while IFS= read -r swift_file; do + SWIFT_FILES+=("$swift_file") +done < <(find Sources Tests -name '*.swift' -not -path '*/Generated/*') + +# Bootstrap tools (mise will install based on .mise.toml) +run_command "$MISE_BIN" install + +if [ -z "$CI" ]; then + run_command $TOOL_CMD swift-format format --configuration .swift-format --parallel --in-place "${SWIFT_FILES[@]}" + run_command $TOOL_CMD swiftlint --fix +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command $TOOL_CMD swift-format lint --configuration .swift-format --parallel $SWIFTFORMAT_LINT_OPTIONS "${SWIFT_FILES[@]}" + run_command $TOOL_CMD swiftlint lint $SWIFTLINT_OPTIONS + # Check for compilation errors + run_command swift build --build-tests +fi + +# header.sh rewrites file headers in place, so it only runs locally — never in CI. +# (It already skips files under a Generated/ directory.) +if [ -z "$CI" ]; then + $PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "TailwindKit" +fi + +if [ -z "$CI" ]; then + run_command $TOOL_CMD periphery scan $PERIPHERY_OPTIONS --disable-update-check +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Sources/TailwindKit/Node+Tailwind.swift b/Sources/TailwindKit/Node+Tailwind.swift new file mode 100644 index 0000000..29dc23c --- /dev/null +++ b/Sources/TailwindKit/Node+Tailwind.swift @@ -0,0 +1,56 @@ +// +// Node+Tailwind.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Plot + +extension Node where Context: HTMLContext { + /// Render a ``TailwindStyle`` into this element's `class` attribute. + /// + /// Sugar for `.class(style.rendered)`: + /// + /// ```swift + /// Node.div(.tailwind(.flex.items(.center).gap(4))) + /// //
+ /// ``` + /// + /// For any class not modeled by ``TailwindStyle``, use Plot's existing + /// `.class("…")` directly. + public static func tailwind(_ style: TailwindStyle) -> Node { + .class(style.rendered) + } +} + +extension Attribute where Context: HTMLContext { + /// Render a ``TailwindStyle`` into this element's `class` attribute. + /// + /// Sugar for `.class(style.rendered)`. + public static func tailwind(_ style: TailwindStyle) -> Attribute { + .class(style.rendered) + } +} diff --git a/Sources/TailwindKit/TailwindStyle+Bare.swift b/Sources/TailwindKit/TailwindStyle+Bare.swift new file mode 100644 index 0000000..ef5078c --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle+Bare.swift @@ -0,0 +1,95 @@ +// +// TailwindStyle+Bare.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension TailwindStyle { + // MARK: Display + + /// `flex`. + public var flex: TailwindStyle { appending("flex") } + /// `inline-flex`. + public var inlineFlex: TailwindStyle { appending("inline-flex") } + /// `grid`. + public var grid: TailwindStyle { appending("grid") } + /// `block`. + public var block: TailwindStyle { appending("block") } + /// `inline-block`. + public var inlineBlock: TailwindStyle { appending("inline-block") } + /// `inline`. + public var inline: TailwindStyle { appending("inline") } + /// `hidden`. + public var hidden: TailwindStyle { appending("hidden") } + + // MARK: Flexbox & grid + + /// `flex-row`. + public var flexRow: TailwindStyle { appending("flex-row") } + /// `flex-col`. + public var flexCol: TailwindStyle { appending("flex-col") } + /// `flex-wrap`. + public var flexWrap: TailwindStyle { appending("flex-wrap") } + /// `grow`. + public var grow: TailwindStyle { appending("grow") } + /// `shrink`. + public var shrink: TailwindStyle { appending("shrink") } + /// `gap` (bare). + public var gap: TailwindStyle { appending("gap") } + + // MARK: Colors + + /// `bg-white`. + public var bgWhite: TailwindStyle { appending("bg-white") } + /// `bg-black`. + public var bgBlack: TailwindStyle { appending("bg-black") } + /// `bg-transparent`. + public var bgTransparent: TailwindStyle { appending("bg-transparent") } + + // MARK: Typography + + /// `text-white`. + public var textWhite: TailwindStyle { appending("text-white") } + /// `text-black`. + public var textBlack: TailwindStyle { appending("text-black") } + /// `italic`. + public var italic: TailwindStyle { appending("italic") } + /// `underline`. + public var underline: TailwindStyle { appending("underline") } + /// `uppercase`. + public var uppercase: TailwindStyle { appending("uppercase") } + /// `lowercase`. + public var lowercase: TailwindStyle { appending("lowercase") } + /// `capitalize`. + public var capitalize: TailwindStyle { appending("capitalize") } + + // MARK: Borders & radius + + /// `border` (1px, bare). + public var border: TailwindStyle { appending("border") } + /// `rounded` (bare). + public var rounded: TailwindStyle { appending("rounded") } +} diff --git a/Sources/TailwindKit/TailwindStyle+Static.swift b/Sources/TailwindKit/TailwindStyle+Static.swift new file mode 100644 index 0000000..9f16836 --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle+Static.swift @@ -0,0 +1,189 @@ +// +// TailwindStyle+Static.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension TailwindStyle { + // MARK: Flexbox & grid + + /// `items-`. + public static func items(_ align: Align) -> TailwindStyle { + TailwindStyle().items(align) + } + /// `justify-`. + public static func justify(_ value: Justify) -> TailwindStyle { + TailwindStyle().justify(value) + } + /// `grid-cols-`. + public static func gridCols(_ count: Int) -> TailwindStyle { + TailwindStyle().gridCols(count) + } + /// `gap-`. + public static func gap(_ amount: Spacing) -> TailwindStyle { + TailwindStyle().gap(amount) + } + /// `gap-x-`. + public static func gapX(_ amount: Spacing) -> TailwindStyle { + TailwindStyle().gapX(amount) + } + /// `gap-y-`. + public static func gapY(_ amount: Spacing) -> TailwindStyle { + TailwindStyle().gapY(amount) + } + + // MARK: Spacing + + /// `p-`. + public static func p(_ amount: Spacing) -> TailwindStyle { TailwindStyle().p(amount) } + /// `px-`. + public static func px(_ amount: Spacing) -> TailwindStyle { TailwindStyle().px(amount) } + /// `py-`. + public static func py(_ amount: Spacing) -> TailwindStyle { TailwindStyle().py(amount) } + /// `pt-`. + public static func pt(_ amount: Spacing) -> TailwindStyle { TailwindStyle().pt(amount) } + /// `pr-`. + public static func pr(_ amount: Spacing) -> TailwindStyle { TailwindStyle().pr(amount) } + /// `pb-`. + public static func pb(_ amount: Spacing) -> TailwindStyle { TailwindStyle().pb(amount) } + /// `pl-`. + public static func pl(_ amount: Spacing) -> TailwindStyle { TailwindStyle().pl(amount) } + /// `m-`. + public static func m(_ amount: Spacing) -> TailwindStyle { TailwindStyle().m(amount) } + /// `mx-`. + public static func mx(_ amount: Spacing) -> TailwindStyle { TailwindStyle().mx(amount) } + /// `my-`. + public static func my(_ amount: Spacing) -> TailwindStyle { TailwindStyle().my(amount) } + /// `mt-`. + public static func mt(_ amount: Spacing) -> TailwindStyle { TailwindStyle().mt(amount) } + /// `mr-`. + public static func mr(_ amount: Spacing) -> TailwindStyle { TailwindStyle().mr(amount) } + /// `mb-`. + public static func mb(_ amount: Spacing) -> TailwindStyle { TailwindStyle().mb(amount) } + /// `ml-`. + public static func ml(_ amount: Spacing) -> TailwindStyle { TailwindStyle().ml(amount) } + + // MARK: Sizing + + /// `w-`. + public static func w(_ size: Size) -> TailwindStyle { TailwindStyle().w(size) } + /// `h-`. + public static func h(_ size: Size) -> TailwindStyle { TailwindStyle().h(size) } + + // MARK: Colors + + /// `bg--`. + public static func bg(_ color: Color, _ shade: Shade) -> TailwindStyle { + TailwindStyle().bg(color, shade) + } + /// `border--`. + public static func borderColor(_ color: Color, _ shade: Shade) -> TailwindStyle { + TailwindStyle().borderColor(color, shade) + } + + // MARK: Typography + + /// `text-`. + public static func text(_ size: TextSize) -> TailwindStyle { + TailwindStyle().text(size) + } + /// `text--`. + public static func text(_ color: Color, _ shade: Shade) -> TailwindStyle { + TailwindStyle().text(color, shade) + } + /// `text-`. + public static func text(_ align: TextAlign) -> TailwindStyle { + TailwindStyle().text(align) + } + /// `font-`. + public static func font(_ weight: FontWeight) -> TailwindStyle { + TailwindStyle().font(weight) + } + + // MARK: Borders & radius + + /// `border-`. + public static func border(_ width: Int) -> TailwindStyle { + TailwindStyle().border(width) + } + /// `rounded-`. + public static func rounded(_ radius: Radius) -> TailwindStyle { + TailwindStyle().rounded(radius) + } + + // MARK: Responsive & state variants + + /// `sm:` variant. + public static func sm(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().sm(style) + } + /// `md:` variant. + public static func md(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().md(style) + } + /// `lg:` variant. + public static func lg(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().lg(style) + } + /// `xl:` variant. + public static func xl(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().xl(style) + } + /// `2xl:` variant. + public static func xl2(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().xl2(style) + } + /// `hover:` variant. + public static func hover(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().hover(style) + } + /// `focus:` variant. + public static func focus(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().focus(style) + } + /// `active:` variant. + public static func active(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().active(style) + } + /// `disabled:` variant. + public static func disabled(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().disabled(style) + } + /// `group-hover:` variant. + public static func groupHover(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().groupHover(style) + } + /// `dark:` variant. + public static func dark(_ style: TailwindStyle) -> TailwindStyle { + TailwindStyle().dark(style) + } +} + +/// Convenient alias for ``TailwindStyle`` so chains can read `TW.flex.gap(4)`. +/// +/// The name is intentionally two characters — `TW` is the mandated public +/// spelling — so `type_name`'s minimum-length rule is disabled on this line. +public typealias TW = TailwindStyle // swiftlint:disable:this type_name diff --git a/Sources/TailwindKit/TailwindStyle+StaticBare.swift b/Sources/TailwindKit/TailwindStyle+StaticBare.swift new file mode 100644 index 0000000..f6bb7b5 --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle+StaticBare.swift @@ -0,0 +1,95 @@ +// +// TailwindStyle+StaticBare.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension TailwindStyle { + // MARK: Display + + /// `flex`. + public static var flex: TailwindStyle { TailwindStyle().flex } + /// `inline-flex`. + public static var inlineFlex: TailwindStyle { TailwindStyle().inlineFlex } + /// `grid`. + public static var grid: TailwindStyle { TailwindStyle().grid } + /// `block`. + public static var block: TailwindStyle { TailwindStyle().block } + /// `inline-block`. + public static var inlineBlock: TailwindStyle { TailwindStyle().inlineBlock } + /// `inline`. + public static var inline: TailwindStyle { TailwindStyle().inline } + /// `hidden`. + public static var hidden: TailwindStyle { TailwindStyle().hidden } + + // MARK: Flexbox & grid + + /// `flex-row`. + public static var flexRow: TailwindStyle { TailwindStyle().flexRow } + /// `flex-col`. + public static var flexCol: TailwindStyle { TailwindStyle().flexCol } + /// `flex-wrap`. + public static var flexWrap: TailwindStyle { TailwindStyle().flexWrap } + /// `grow`. + public static var grow: TailwindStyle { TailwindStyle().grow } + /// `shrink`. + public static var shrink: TailwindStyle { TailwindStyle().shrink } + /// `gap` (bare). + public static var gap: TailwindStyle { TailwindStyle().gap } + + // MARK: Colors + + /// `bg-white`. + public static var bgWhite: TailwindStyle { TailwindStyle().bgWhite } + /// `bg-black`. + public static var bgBlack: TailwindStyle { TailwindStyle().bgBlack } + /// `bg-transparent`. + public static var bgTransparent: TailwindStyle { TailwindStyle().bgTransparent } + + // MARK: Typography + + /// `text-white`. + public static var textWhite: TailwindStyle { TailwindStyle().textWhite } + /// `text-black`. + public static var textBlack: TailwindStyle { TailwindStyle().textBlack } + /// `italic`. + public static var italic: TailwindStyle { TailwindStyle().italic } + /// `underline`. + public static var underline: TailwindStyle { TailwindStyle().underline } + /// `uppercase`. + public static var uppercase: TailwindStyle { TailwindStyle().uppercase } + /// `lowercase`. + public static var lowercase: TailwindStyle { TailwindStyle().lowercase } + /// `capitalize`. + public static var capitalize: TailwindStyle { TailwindStyle().capitalize } + + // MARK: Borders & radius + + /// `border` (bare). + public static var border: TailwindStyle { TailwindStyle().border } + /// `rounded` (bare). + public static var rounded: TailwindStyle { TailwindStyle().rounded } +} diff --git a/Sources/TailwindKit/TailwindStyle+Tokens.swift b/Sources/TailwindKit/TailwindStyle+Tokens.swift new file mode 100644 index 0000000..8007d35 --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle+Tokens.swift @@ -0,0 +1,209 @@ +// +// TailwindStyle+Tokens.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension TailwindStyle { + /// A Tailwind v4 color family (the `blue` in `bg-blue-500`). + public enum Color: String, Sendable, CaseIterable { + case slate, gray, zinc, neutral, stone + case red, orange, amber, yellow, lime + case green, emerald, teal, cyan, sky + case blue, indigo, violet, purple, fuchsia + case pink, rose + + /// The token fragment, e.g. `"blue"`. + internal var token: String { rawValue } + } + + /// A Tailwind v4 color shade (the `500` in `bg-blue-500`). + /// + /// Spelled `sNNN` because Swift identifiers cannot begin with a digit and + /// leading underscores are disallowed — e.g. `.bg(.blue, .s500)`. + public enum Shade: Int, Sendable, CaseIterable { + case s50 = 50 + case s100 = 100 + case s200 = 200 + case s300 = 300 + case s400 = 400 + case s500 = 500 + case s600 = 600 + case s700 = 700 + case s800 = 800 + case s900 = 900 + case s950 = 950 + + /// The token fragment, e.g. `"500"`. + internal var token: String { String(rawValue) } + } +} + +// MARK: - Spacing + +extension TailwindStyle { + /// A value on Tailwind v4's dynamic spacing scale (the `4` in `gap-4`, + /// the `2.5` in `p-2.5`). + /// + /// Expressible by integer and floating-point literals so call sites read + /// naturally: `.gap(4)`, `.p(2.5)`. + public struct Spacing: Sendable, Equatable, Hashable, + ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral + { + /// The `px` keyword (a single CSS pixel), e.g. `p-px`. + public static let px = Spacing(token: "px") // swiftlint:disable:this identifier_name + + internal let token: String + + /// Creates a spacing value from an integer literal (e.g. `4` renders `4`). + public init(integerLiteral value: Int) { + self.token = String(value) + } + + /// Creates a spacing value from a floating-point literal, dropping a + /// trailing `.0` (e.g. `2.5` renders `2.5`, `2.0` renders `2`). + public init(floatLiteral value: Double) { + self.token = + value.rounded() == value + ? String(Int(value)) + : String(value) + } + + private init(token: String) { + self.token = token + } + } +} + +// MARK: - Sizing + +extension TailwindStyle { + /// A width/height value: a spacing-scale number (`w-4`) or a keyword + /// (`w-full`, `h-screen`). + public struct Size: Sendable, Equatable, Hashable, + ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral + { + /// `full` — 100%. + public static let full = Size(token: "full") + /// `screen` — the viewport dimension. + public static let screen = Size(token: "screen") + /// `auto`. + public static let auto = Size(token: "auto") + /// `min` — `min-content`. + public static let min = Size(token: "min") + /// `max` — `max-content`. + public static let max = Size(token: "max") + /// `fit` — `fit-content`. + public static let fit = Size(token: "fit") + + internal let token: String + + /// Creates a size from an integer literal on the spacing scale (e.g. `4`). + public init(integerLiteral value: Int) { + self.token = String(value) + } + + /// Creates a size from a floating-point literal, dropping a trailing `.0`. + public init(floatLiteral value: Double) { + self.token = + value.rounded() == value + ? String(Int(value)) + : String(value) + } + + private init(token: String) { + self.token = token + } + } +} + +// MARK: - Flex / Grid alignment + +extension TailwindStyle { + /// Cross-axis alignment for `items-*` (`align-items`). + public enum Align: String, Sendable, CaseIterable { + case start, center, end, baseline, stretch + + internal var token: String { rawValue } + } + + /// Main-axis distribution for `justify-*` (`justify-content`). + public enum Justify: String, Sendable, CaseIterable { + case start, center, end, between, around, evenly + + internal var token: String { rawValue } + } +} + +// MARK: - Typography + +extension TailwindStyle { + /// A font weight (the `medium` in `font-medium`). + public enum FontWeight: String, Sendable, CaseIterable { + case thin, extralight, light, normal, medium + case semibold, bold, extrabold, black + + internal var token: String { rawValue } + } + + /// A font size on the `text-*` scale (the `lg` in `text-lg`). + public enum TextSize: String, Sendable, CaseIterable { + // swiftlint:disable identifier_name + case xs, sm, base, lg, xl + case xl2 = "2xl" + case xl3 = "3xl" + case xl4 = "4xl" + case xl5 = "5xl" + case xl6 = "6xl" + case xl7 = "7xl" + case xl8 = "8xl" + case xl9 = "9xl" + // swiftlint:enable identifier_name + + internal var token: String { rawValue } + } + + /// A text-alignment keyword (the `center` in `text-center`). + public enum TextAlign: String, Sendable, CaseIterable { + case left, center, right, justify, start, end + + internal var token: String { rawValue } + } + + /// A border-radius scale value (the `lg` in `rounded-lg`). + public enum Radius: String, Sendable, CaseIterable { + // swiftlint:disable identifier_name + case none, sm + case base = "" + case md, lg, xl + case xl2 = "2xl" + case xl3 = "3xl" + case full + // swiftlint:enable identifier_name + + internal var token: String { rawValue } + } +} diff --git a/Sources/TailwindKit/TailwindStyle+Utilities.swift b/Sources/TailwindKit/TailwindStyle+Utilities.swift new file mode 100644 index 0000000..92eb0ba --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle+Utilities.swift @@ -0,0 +1,190 @@ +// +// TailwindStyle+Utilities.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension TailwindStyle { + // MARK: Flexbox & grid + + /// `items-` — cross-axis alignment, e.g. `.items(.center)`. + public func items(_ align: Align) -> TailwindStyle { + appending("items-\(align.token)") + } + + /// `justify-` — main-axis distribution, e.g. `.justify(.between)`. + public func justify(_ value: Justify) -> TailwindStyle { + appending("justify-\(value.token)") + } + + /// `grid-cols-`, e.g. `.gridCols(3)`. + public func gridCols(_ count: Int) -> TailwindStyle { + appending("grid-cols-\(count)") + } + + /// `gap-`, e.g. `.gap(4)`. + public func gap(_ amount: Spacing) -> TailwindStyle { + appending("gap-\(amount.token)") + } + + /// `gap-x-`. + public func gapX(_ amount: Spacing) -> TailwindStyle { + appending("gap-x-\(amount.token)") + } + + /// `gap-y-`. + public func gapY(_ amount: Spacing) -> TailwindStyle { + appending("gap-y-\(amount.token)") + } + + // MARK: Spacing (padding & margin) + + /// `p-`. + public func p(_ amount: Spacing) -> TailwindStyle { appending("p-\(amount.token)") } + /// `px-`. + public func px(_ amount: Spacing) -> TailwindStyle { appending("px-\(amount.token)") } + /// `py-`. + public func py(_ amount: Spacing) -> TailwindStyle { appending("py-\(amount.token)") } + /// `pt-`. + public func pt(_ amount: Spacing) -> TailwindStyle { appending("pt-\(amount.token)") } + /// `pr-`. + public func pr(_ amount: Spacing) -> TailwindStyle { appending("pr-\(amount.token)") } + /// `pb-`. + public func pb(_ amount: Spacing) -> TailwindStyle { appending("pb-\(amount.token)") } + /// `pl-`. + public func pl(_ amount: Spacing) -> TailwindStyle { appending("pl-\(amount.token)") } + /// `m-`. + public func m(_ amount: Spacing) -> TailwindStyle { appending("m-\(amount.token)") } + /// `mx-`. + public func mx(_ amount: Spacing) -> TailwindStyle { appending("mx-\(amount.token)") } + /// `my-`. + public func my(_ amount: Spacing) -> TailwindStyle { appending("my-\(amount.token)") } + /// `mt-`. + public func mt(_ amount: Spacing) -> TailwindStyle { appending("mt-\(amount.token)") } + /// `mr-`. + public func mr(_ amount: Spacing) -> TailwindStyle { appending("mr-\(amount.token)") } + /// `mb-`. + public func mb(_ amount: Spacing) -> TailwindStyle { appending("mb-\(amount.token)") } + /// `ml-`. + public func ml(_ amount: Spacing) -> TailwindStyle { appending("ml-\(amount.token)") } + + // MARK: Sizing + + /// `w-`, e.g. `.w(.full)` or `.w(4)`. + public func w(_ size: Size) -> TailwindStyle { appending("w-\(size.token)") } + /// `h-`, e.g. `.h(.screen)` or `.h(4)`. + public func h(_ size: Size) -> TailwindStyle { appending("h-\(size.token)") } + + // MARK: Colors + + /// `bg--`, e.g. `.bg(.blue, .s500)`. + public func bg(_ color: Color, _ shade: Shade) -> TailwindStyle { + appending("bg-\(color.token)-\(shade.token)") + } + + /// `border--`, e.g. `.borderColor(.gray, .s200)`. + public func borderColor(_ color: Color, _ shade: Shade) -> TailwindStyle { + appending("border-\(color.token)-\(shade.token)") + } + + // MARK: Typography + + /// `text-`, e.g. `.text(.lg)`. + public func text(_ size: TextSize) -> TailwindStyle { + appending("text-\(size.token)") + } + + /// `text--`, e.g. `.text(.blue, .s500)`. + public func text(_ color: Color, _ shade: Shade) -> TailwindStyle { + appending("text-\(color.token)-\(shade.token)") + } + + /// `text-`, e.g. `.text(.center)`. + public func text(_ align: TextAlign) -> TailwindStyle { + appending("text-\(align.token)") + } + + /// `font-`, e.g. `.font(.medium)`. + public func font(_ weight: FontWeight) -> TailwindStyle { + appending("font-\(weight.token)") + } + + // MARK: Borders & radius + + /// `border-`, e.g. `.border(2)`. + public func border(_ width: Int) -> TailwindStyle { appending("border-\(width)") } + + /// `rounded-`, e.g. `.rounded(.lg)`. + public func rounded(_ radius: Radius) -> TailwindStyle { + radius.token.isEmpty + ? appending("rounded") + : appending("rounded-\(radius.token)") + } + + // MARK: Responsive & state variants + // + // Each takes a nested style and prefixes every one of its tokens. Prefixes + // stack, so `.md(.hover(.bg(.blue, .s700)))` renders `md:hover:bg-blue-700`. + + /// `sm:` — ≥ 40rem breakpoint. + public func sm(_ style: TailwindStyle) -> TailwindStyle { prefixing("sm", style) } + /// `md:` — ≥ 48rem breakpoint. + public func md(_ style: TailwindStyle) -> TailwindStyle { prefixing("md", style) } + /// `lg:` — ≥ 64rem breakpoint. + public func lg(_ style: TailwindStyle) -> TailwindStyle { prefixing("lg", style) } + /// `xl:` — ≥ 80rem breakpoint. + public func xl(_ style: TailwindStyle) -> TailwindStyle { prefixing("xl", style) } + /// `2xl:` — ≥ 96rem breakpoint. + public func xl2(_ style: TailwindStyle) -> TailwindStyle { prefixing("2xl", style) } + + /// `hover:` state variant. + public func hover(_ style: TailwindStyle) -> TailwindStyle { + prefixing("hover", style) + } + + /// `focus:` state variant. + public func focus(_ style: TailwindStyle) -> TailwindStyle { + prefixing("focus", style) + } + + /// `active:` state variant. + public func active(_ style: TailwindStyle) -> TailwindStyle { + prefixing("active", style) + } + + /// `disabled:` state variant. + public func disabled(_ style: TailwindStyle) -> TailwindStyle { + prefixing("disabled", style) + } + + /// `group-hover:` state variant. + public func groupHover(_ style: TailwindStyle) -> TailwindStyle { + prefixing("group-hover", style) + } + + /// `dark:` color-scheme variant. + public func dark(_ style: TailwindStyle) -> TailwindStyle { prefixing("dark", style) } +} diff --git a/Sources/TailwindKit/TailwindStyle.swift b/Sources/TailwindKit/TailwindStyle.swift new file mode 100644 index 0000000..89ba7d1 --- /dev/null +++ b/Sources/TailwindKit/TailwindStyle.swift @@ -0,0 +1,94 @@ +// +// TailwindStyle.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A type-safe, fluent builder for [Tailwind CSS v4](https://tailwindcss.com) +/// utility class strings. +/// +/// `TailwindStyle` is a pure value type with **no dependency on Plot** (or any +/// HTML library). Every member — bare utilities exposed as computed properties +/// and parameterized utilities exposed as methods — returns a new +/// `TailwindStyle`, so styles are composed by chaining: +/// +/// ```swift +/// TW.flex.items(.center).gap(4).bg(.blue, .s500).rendered +/// // "flex items-center gap-4 bg-blue-500" +/// ``` +/// +/// The final class string is produced by ``rendered``. To attach a style to a +/// Plot element, use the single `.tailwind(_:)` convenience (see +/// `Node+Tailwind.swift`): +/// +/// ```swift +/// Node.div(.tailwind(.flex.items(.center).gap(4)), .text("Hi")) +/// ``` +/// +/// The set of modeled utilities is intentionally **closed** and grown +/// component-driven: add cases as components need them. For any class not yet +/// modeled, the escape hatch is Plot's existing `.class("…")` — `TailwindStyle` +/// itself never accepts raw strings. +public struct TailwindStyle: Sendable, Equatable, Hashable { + /// The ordered, fully-prefixed utility tokens (e.g. `"items-center"`, + /// `"md:gap-4"`), rendered space-separated by ``rendered``. + private let tokens: [String] + + /// The composed Tailwind class string, tokens joined by a single space. + /// + /// ```swift + /// TW.flex.gap(4).rendered // "flex gap-4" + /// ``` + public var rendered: String { + tokens.joined(separator: " ") + } + + /// Creates an empty style. + /// + /// Chain utilities to build it up, or start a chain from a static member + /// such as `.flex`. + public init() { + self.tokens = [] + } + + private init(tokens: [String]) { + self.tokens = tokens + } + + /// Returns a new style with `token` appended. + internal func appending(_ token: String) -> TailwindStyle { + TailwindStyle(tokens: tokens + [token]) + } + + /// Returns a new style with every token of `other` prefixed by + /// `"\(prefix):"` and appended. + /// + /// Used to model responsive/state variants (e.g. `md`, `hover`); prefixes + /// stack, so `.md(.hover(.bg(.blue, .s700)))` renders `"md:hover:bg-blue-700"`. + internal func prefixing(_ prefix: String, _ other: TailwindStyle) -> TailwindStyle { + TailwindStyle(tokens: tokens + other.tokens.map { "\(prefix):\($0)" }) + } +} diff --git a/Tests/TailwindKitTests/TailwindStyleTests.swift b/Tests/TailwindKitTests/TailwindStyleTests.swift new file mode 100644 index 0000000..aa5b44e --- /dev/null +++ b/Tests/TailwindKitTests/TailwindStyleTests.swift @@ -0,0 +1,141 @@ +// +// TailwindStyleTests.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +import Testing + +@testable import TailwindKit + +// Plot-independent: these assert `.rendered` string equality only; nothing here +// imports Plot. +@Suite internal struct TailwindStyleTests { + @Test internal func emptyRendersEmptyString() { + #expect(TailwindStyle().rendered.isEmpty) + #expect(TW().rendered.isEmpty) + } + + @Test internal func confirmedCanonicalExample() { + #expect( + TW.flex.items(.center).gap(4).bg(.blue, .s500).rendered + == "flex items-center gap-4 bg-blue-500" + ) + } + + @Test internal func planExample() { + #expect( + TW.flex.items(.center).gap(4).rendered == "flex items-center gap-4" + ) + } + + @Test internal func colorShadeToken() { + #expect(TW.bg(.blue, .s500).rendered == "bg-blue-500") + #expect(TW.text(.gray, .s700).rendered == "text-gray-700") + #expect(TW.borderColor(.slate, .s200).rendered == "border-slate-200") + } + + @Test internal func bareUtilitiesAreProperties() { + #expect(TW.flex.rendered == "flex") + #expect(TW.grid.rendered == "grid") + #expect(TW.hidden.rendered == "hidden") + #expect(TW.italic.underline.rendered == "italic underline") + #expect(TW.gap.rendered == "gap") + } + + @Test internal func spacingScale() { + #expect(TW.p(4).rendered == "p-4") + #expect(TW.px(2.5).rendered == "px-2.5") + #expect(TW.my(0).mt(8).rendered == "my-0 mt-8") + #expect(TW.p(.px).rendered == "p-px") + // A whole-number float drops its trailing ".0". + #expect(TW.gap(2.0).rendered == "gap-2") + } + + @Test internal func sizing() { + #expect(TW.w(.full).h(.screen).rendered == "w-full h-screen") + #expect(TW.w(4).rendered == "w-4") + #expect(TW.h(.auto).rendered == "h-auto") + } + + @Test internal func typography() { + #expect(TW.text(.lg).font(.medium).rendered == "text-lg font-medium") + #expect(TW.text(.xl2).rendered == "text-2xl") + #expect(TW.text(.center).rendered == "text-center") + #expect(TW.text(.blue, .s500).rendered == "text-blue-500") + } + + @Test internal func flexAndGrid() { + #expect( + TW.flex.flexCol.justify(.between).items(.stretch).rendered + == "flex flex-col justify-between items-stretch" + ) + #expect(TW.grid.gridCols(3).gap(6).rendered == "grid grid-cols-3 gap-6") + } + + @Test internal func bordersAndRadius() { + #expect(TW.border.rounded.rendered == "border rounded") + #expect(TW.border(2).rounded(.lg).rendered == "border-2 rounded-lg") + // `rounded(.base)` collapses to bare `rounded`. + #expect(TW.rounded(.base).rendered == "rounded") + #expect(TW.rounded(.full).rendered == "rounded-full") + } + + @Test internal func responsivePrefix() { + #expect( + TW.flex.md(.gap(4).items(.center)).rendered + == "flex md:gap-4 md:items-center" + ) + #expect(TW.block.lg(.hidden).rendered == "block lg:hidden") + } + + @Test internal func statePrefix() { + #expect( + TW.bg(.blue, .s500).hover(.bg(.blue, .s700)).rendered + == "bg-blue-500 hover:bg-blue-700" + ) + } + + @Test internal func stackedPrefixes() { + #expect(TW.md(.hover(.bg(.blue, .s700))).rendered == "md:hover:bg-blue-700") + #expect(TW.dark(.text(.gray, .s100)).rendered == "dark:text-gray-100") + } + + @Test internal func staticAndInstanceEntryPointsAgree() { + // Leading-dot static entry vs. explicit-empty instance chain. + let flexGapViaInstance = TailwindStyle().flex.gap(4) + #expect(TailwindStyle.flex.gap(4) == flexGapViaInstance) + let bgViaInstance = TailwindStyle().bg(.blue, .s500) + #expect(TW.bg(.blue, .s500) == bgViaInstance) + } + + @Test internal func equatable() { + let lhs = TW.flex.gap(4) + let rhs = TW.flex.gap(4) + #expect(lhs == rhs) + #expect(lhs != TW.flex.gap(2)) + } +} diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..951b97b --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "Tests" From ff3fa274f35e9ffebe9ee73504313d5dd87d3f61 Mon Sep 17 00:00:00 2001 From: leogdion Date: Fri, 17 Jul 2026 10:19:40 -0400 Subject: [PATCH 02/22] #67 Component-based Plot API migration + TailwindKit v4 coverage + #53 HTMLFactory seam (#157) --- CLAUDE.md | 116 ++++++++-- README.md | 25 ++- .../Core/DefaultTailwindClass.swift | 43 ++++ Sources/TailwindKit/Core/DefaultVariant.swift | 94 ++++++++ Sources/TailwindKit/Core/TW.swift | 34 +++ Sources/TailwindKit/Core/TailwindClass.swift | 50 +++++ Sources/TailwindKit/Core/TailwindStyle.swift | 64 ++++++ .../TailwindStyleBuilder.swift} | 42 +++- Sources/TailwindKit/Core/TailwindToken.swift | 63 ++++++ Sources/TailwindKit/Core/Variant.swift | 49 ++++ .../{ => Plot}/Node+Tailwind.swift | 10 +- .../Styling/ArbitraryStyling.swift | 92 ++++++++ .../TailwindKit/Styling/BorderStyling.swift | 106 +++++++++ .../TailwindKit/Styling/ColorStyling.swift | 178 +++++++++++++++ .../TailwindKit/Styling/DisplayStyling.swift | 105 +++++++++ .../TailwindKit/Styling/EffectsStyling.swift | 161 ++++++++++++++ .../TailwindKit/Styling/FlexGridStyling.swift | 168 ++++++++++++++ Sources/TailwindKit/Styling/ListStyling.swift | 55 +++++ .../Styling/PositioningStyling.swift | 115 ++++++++++ .../TailwindKit/Styling/SizingStyling.swift | 85 +++++++ .../TailwindKit/Styling/SpacingStyling.swift | 188 ++++++++++++++++ .../TailwindStyleBuilder+FlexGridStatic.swift | 110 +++++++++ .../Styling/TransitionStyling.swift | 113 ++++++++++ .../Styling/TypographyStyling.swift | 190 ++++++++++++++++ .../TailwindKit/Styling/VariantStyling.swift | 145 ++++++++++++ Sources/TailwindKit/TailwindStyle+Bare.swift | 95 -------- .../TailwindKit/TailwindStyle+Static.swift | 189 ---------------- .../TailwindStyle+StaticBare.swift | 95 -------- .../TailwindKit/TailwindStyle+Tokens.swift | 209 ------------------ .../TailwindKit/TailwindStyle+Utilities.swift | 190 ---------------- Sources/TailwindKit/Tokens/Closed/Align.swift | 35 +++ .../Tokens/Closed/BorderSide.swift | 38 ++++ Sources/TailwindKit/Tokens/Closed/Flex.swift | 42 ++++ .../Tokens/Closed/FlexDirection.swift | 38 ++++ .../TailwindKit/Tokens/Closed/Justify.swift | 35 +++ .../TailwindKit/Tokens/Closed/ListStyle.swift | 36 +++ .../TailwindKit/Tokens/Closed/ObjectFit.swift | 39 ++++ .../TailwindKit/Tokens/Closed/Position.swift | 38 ++++ Sources/TailwindKit/Tokens/Closed/Shade.swift | 52 +++++ .../TailwindKit/Tokens/Closed/TextAlign.swift | 38 ++++ .../Tokens/Closed/VerticalAlign.swift | 38 ++++ .../TailwindKit/Tokens/Extensible/Color.swift | 39 ++++ .../Tokens/Extensible/DefaultColor.swift | 134 +++++++++++ .../Tokens/Extensible/DefaultDropShadow.swift | 74 +++++++ .../Tokens/Extensible/DefaultEase.swift | 58 +++++ .../Tokens/Extensible/DefaultFontWeight.swift | 78 +++++++ .../Tokens/Extensible/DefaultMaxWidth.swift | 76 +++++++ .../Tokens/Extensible/DefaultRadius.swift | 86 +++++++ .../Tokens/Extensible/DefaultShadow.swift | 78 +++++++ .../Tokens/Extensible/DefaultSize.swift | 97 ++++++++ .../Tokens/Extensible/DefaultSpacing.swift | 86 +++++++ .../Tokens/Extensible/DefaultTextSize.swift | 98 ++++++++ .../Tokens/Extensible/DefaultTracking.swift | 66 ++++++ .../Tokens/Extensible/DropShadow.swift | 34 +++ .../TailwindKit/Tokens/Extensible/Ease.swift | 35 +++ .../Tokens/Extensible/FontWeight.swift | 34 +++ .../Tokens/Extensible/MaxWidth.swift | 37 ++++ .../Tokens/Extensible/Radius.swift | 37 ++++ .../Tokens/Extensible/Shadow.swift | 36 +++ .../TailwindKit/Tokens/Extensible/Size.swift | 36 +++ .../Tokens/Extensible/Spacing.swift | 37 ++++ .../Tokens/Extensible/TextSize.swift | 35 +++ .../Tokens/Extensible/Tracking.swift | 34 +++ .../TailwindStyleCoverageTests.swift | 153 +++++++++++++ .../TailwindStyleExtensibilityTests.swift | 144 ++++++++++++ .../TailwindStyleSetsAndCustomTests.swift | 123 +++++++++++ .../TailwindKitTests/TailwindStyleTests.swift | 10 +- docs/tailwind-v4-value-model.md | 66 ++++++ 68 files changed, 4612 insertions(+), 817 deletions(-) create mode 100644 Sources/TailwindKit/Core/DefaultTailwindClass.swift create mode 100644 Sources/TailwindKit/Core/DefaultVariant.swift create mode 100644 Sources/TailwindKit/Core/TW.swift create mode 100644 Sources/TailwindKit/Core/TailwindClass.swift create mode 100644 Sources/TailwindKit/Core/TailwindStyle.swift rename Sources/TailwindKit/{TailwindStyle.swift => Core/TailwindStyleBuilder.swift} (65%) create mode 100644 Sources/TailwindKit/Core/TailwindToken.swift create mode 100644 Sources/TailwindKit/Core/Variant.swift rename Sources/TailwindKit/{ => Plot}/Node+Tailwind.swift (81%) create mode 100644 Sources/TailwindKit/Styling/ArbitraryStyling.swift create mode 100644 Sources/TailwindKit/Styling/BorderStyling.swift create mode 100644 Sources/TailwindKit/Styling/ColorStyling.swift create mode 100644 Sources/TailwindKit/Styling/DisplayStyling.swift create mode 100644 Sources/TailwindKit/Styling/EffectsStyling.swift create mode 100644 Sources/TailwindKit/Styling/FlexGridStyling.swift create mode 100644 Sources/TailwindKit/Styling/ListStyling.swift create mode 100644 Sources/TailwindKit/Styling/PositioningStyling.swift create mode 100644 Sources/TailwindKit/Styling/SizingStyling.swift create mode 100644 Sources/TailwindKit/Styling/SpacingStyling.swift create mode 100644 Sources/TailwindKit/Styling/TailwindStyleBuilder+FlexGridStatic.swift create mode 100644 Sources/TailwindKit/Styling/TransitionStyling.swift create mode 100644 Sources/TailwindKit/Styling/TypographyStyling.swift create mode 100644 Sources/TailwindKit/Styling/VariantStyling.swift delete mode 100644 Sources/TailwindKit/TailwindStyle+Bare.swift delete mode 100644 Sources/TailwindKit/TailwindStyle+Static.swift delete mode 100644 Sources/TailwindKit/TailwindStyle+StaticBare.swift delete mode 100644 Sources/TailwindKit/TailwindStyle+Tokens.swift delete mode 100644 Sources/TailwindKit/TailwindStyle+Utilities.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/Align.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/BorderSide.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/Flex.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/FlexDirection.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/Justify.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/ListStyle.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/ObjectFit.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/Position.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/Shade.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/TextAlign.swift create mode 100644 Sources/TailwindKit/Tokens/Closed/VerticalAlign.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Color.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultColor.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultDropShadow.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultEase.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultFontWeight.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultMaxWidth.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultRadius.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultShadow.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultSize.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultSpacing.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultTextSize.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DefaultTracking.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/DropShadow.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Ease.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/FontWeight.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/MaxWidth.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Radius.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Shadow.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Size.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Spacing.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/TextSize.swift create mode 100644 Sources/TailwindKit/Tokens/Extensible/Tracking.swift create mode 100644 Tests/TailwindKitTests/TailwindStyleCoverageTests.swift create mode 100644 Tests/TailwindKitTests/TailwindStyleExtensibilityTests.swift create mode 100644 Tests/TailwindKitTests/TailwindStyleSetsAndCustomTests.swift create mode 100644 docs/tailwind-v4-value-model.md diff --git a/CLAUDE.md b/CLAUDE.md index 48d3958..1fb9ebe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,25 +31,107 @@ only when `CI` is unset; in CI it lints and builds without mutating files. ## Architecture — the one design decision -`TailwindStyle` is an immutable value builder that is **Plot-independent**. The -core builder and its utility surface live in Plot-free files -(`TailwindStyle.swift`, `TailwindStyle+Tokens.swift`, -`TailwindStyle+Utilities.swift`, `TailwindStyle+Static.swift`). The **only** file -that imports Plot is `Node+Tailwind.swift`, which adds the single sugar -`.tailwind(_ style:)` → `.class(style.rendered)` on `Node`/`Attribute`. - -- Every fluent member returns a new `TailwindStyle`. Bare utilities are computed +`TailwindStyleBuilder` is an immutable value builder that is **Plot-independent**. +The core builder and its utility surface live in Plot-free files +(`TailwindStyleBuilder.swift`, the token files, and the +`*Styling.swift` capability files). The **only** file that imports Plot is +`Node+Tailwind.swift`, which adds the single sugar `.tailwind(_ style:)` → +`.class(style.rendered)` on `Node`/`Attribute`. (The plain name `TailwindStyle` +is the **seam protocol**, see below; `TW` is the typealias for the builder.) + +- Every fluent member returns a new `TailwindStyleBuilder`. Bare utilities are computed properties (`.flex`, `.gap`); parameterized ones are methods (`.gap(4)`, - `.bg(.blue, .s500)`). Static mirrors in `TailwindStyle+Static.swift` let a - chain start with a leading dot. -- To satisfy SwiftLint's `type_contents_order`, each of the utilities/static - files is split into a **bare-utilities (properties)** extension and a - **parameterized-utilities (methods)** extension. -- The modeled surface is a **closed** set of enums, grown component-driven for - consumers (issue #67). The escape hatch for unmodeled classes is Plot's - existing `.class("…")`; TailwindKit takes no raw strings. + `.bg(.blue, .s500)`). Static mirrors let a chain start with a leading dot. + +### The capability-protocol architecture (mirrors ButtondownKit) + +The fluent surface is organized into ~13 **public capability protocols** — one +per CSS concern, noun + `Styling` suffix — each in its own file +(`ColorStyling.swift`, `SpacingStyling.swift`, `FlexGridStyling.swift`, …). This +mirrors `ButtondownKit`'s capability-protocol pattern (`EmailListing`, … witnessed +in extensions constrained on the `UnderlyingClientProtocol` seam). + +- **The seam is `TailwindStyle`** (`TailwindStyle.swift`), a + **public** protocol exposing the two composition primitives + `appending(_ class: some TailwindClass)` and + `prefixing(_ variant: some Variant, _:)`. Each capability provides its members + in `extension XStyling where Self: TailwindStyle { public func … }`, + composing through the seam; `TailwindStyleBuilder` conforms to the seam + every + capability. The seam **must** be public — Swift forbids a `public` member in an + extension constrained on a non-public protocol. +- **Why the seam takes typed values, not `String`.** A naive port of + ButtondownKit's `underlying` seam would expose `appending(_:String)`, breaking + the invariant that the public API never accepts raw strings. Instead the seam + takes `some TailwindClass` (a full class fragment; built-ins via + `DefaultTailwindClass`, **internal init**) and `some Variant`. So the seam is + public yet there is **no raw-string entry point**. The actual string + composition (`appendingToken`/`prefixingToken`) is **file-private** in + `TailwindStyleBuilder.swift`. +- **`Variant`** (`Variant.swift`) is extensible exactly like `Color` — a + `public protocol Variant: TailwindToken` with `DefaultVariant` (internal init) + exposing `.sm`/`.md`/`.hover`/`.dark`/… A downstream module can register a + custom `@custom-variant` by conforming its own type. Variants **chain by + nesting**: `.md(.hover(.bg(.blue, .s700)))` → `md:hover:bg-blue-700`. +- **Deliberate divergence from ButtondownKit:** `TailwindStyleBuilder` retains method + bodies (the seam witnesses) rather than being pure storage, because a value + type has no injected `underlying` collaborator to hide — the "seam" is an + implementation detail, not a dependency. +- To satisfy SwiftLint's `type_contents_order`, within each capability file the + witnesses and static mirrors are split into **properties-before-methods** + extensions as needed. Every public witness carries a doc comment + (`missing_docs` is opt-in and CI runs `--strict`). +- **Value tokens split by whether Tailwind v4 makes them customizable** (see + `docs/tailwind-v4-value-model.md` for the per-family rationale, cited to the v4 + docs). The families backed by an extensible `@theme` namespace are modeled as a + **protocol + `Default…` type (SwiftUI-`Style` shape)**: `Color`, `Spacing`, + `Size`, `MaxWidth` (`--container-*`), `TextSize`, `FontWeight`, `Radius`, + `Shadow`, `DropShadow`, `Tracking`, `Ease`. The fixed CSS-keyword families stay + **closed enums** (custom values are meaningless): `Shade`, `Position`, `Flex`, + `FlexDirection`, `ListStyle`, `Align`, `Justify`, `TextAlign`, `VerticalAlign`, + `ObjectFit`, `BorderSide`. The base protocol is in `TailwindToken.swift`. +- **Token types are top-level, bare-named, one per file.** Each is its own file: + a protocol in `Color.swift`/`Radius.swift`/…, its `Default…` struct (carrying + the `where Self ==` static members) in `DefaultColor.swift`/…, each closed enum + in `Shade.swift`/`Position.swift`/… (one-declaration-per-file, so no lint + suppressions). They were previously nested as `TailwindStyleBuilder.Color`; un-nesting + removed ~375 `TailwindStyleBuilder.` qualifications across the module. **Tradeoff:** + bare `Color`/`Size`/`Position`/… can collide with `SwiftUI.Color` etc. in a + downstream file importing both — such a caller must write `TailwindKit.Color`. + TailwindKit is server-side HTML with no SwiftUI consumers, so this is latent. + (The **seam** types `TailwindClass`/`Variant` — and their `Default…` structs — are + also top-level, bare-named, one per file, matching the token types.) +- **The extensible-token pattern (Approach C):** each family is a marker + `public protocol Foo: TailwindToken {}`; built-ins live on a `public struct + DefaultFoo: Foo` whose init is **internal** (so it is *not constructible by + name* — like SwiftUI's `DefaultButtonStyle`). Each built-in value is a + `public static let` **on `DefaultFoo`** (e.g. `DefaultColor.slate`), and the + leading-dot surface is a thin computed forwarder in + `extension Foo where Self == DefaultFoo { public static var slate: DefaultColor { .slate } }` + (the `where Self ==` constraint is required for `.slate`-in-`some Foo` leading-dot + syntax, and forces `DefaultFoo` to be a public type; the forwarder must stay a + computed `var` because a stored `static let` cannot live in an extension — it + resolves to the concrete `DefaultFoo.slate` constant, so there is no recursion). Fluent methods take `some Foo` so a downstream module can + add a custom value by conforming its own type: `struct BrandColor: Color + { let token = "brand" }` → `TW.bg(.brand, .s500)`. Literal families + (`Spacing`/`Size`) instead take the concrete `DefaultSpacing`/`DefaultSize` + so `.gap(4)`/`.p(2.5)` still work. +- Related bare utilities are still grouped into cohesive enum "sets" (e.g. + `Position`, `Flex`, `FlexDirection`, `ListStyle`) rather than a flat wall of + computed properties. +- For Tailwind v4 [arbitrary values](https://tailwindcss.com/docs/adding-custom-styles): + each extensible token family carries a `.arbitrary(_:)` static (e.g. + `.maxW(.arbitrary("48rem"))` → `max-w-[48rem]`, spaces→underscores), and a + developer can also conform a token type whose `token` is the bracketed form. + For a value on an **unmodeled** utility prefix, `TailwindStyle+Arbitrary.swift` + provides `.arbitrary(_ prefix:value:)` (→ `prefix-[value]`), + `.arbitrary(_ prefix:variable:)` (→ `prefix-(--var)`), and + `.custom(property:value:)` (→ `[property:value]`). The removed + `TailwindStyle+Custom.swift`/`Custom` type is gone. For any class not modeled at + all, the escape hatch remains Plot's existing `.class("…")`. - Shades are enum cases `.s50`…`.s950` (Swift disallows the `.500` spelling and - leading underscores), e.g. `.bg(.blue, .s500)`. + leading underscores), e.g. `.bg(.blue, .s500)`. Shade is deliberately **not** + extensible: in v4 `blue-500` is a single `--color-blue-500` variable, so a + custom shade isn't a coherent concept — add a custom `Color` instead. ## Tests diff --git a/README.md b/README.md index 65d458e..28280e1 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,33 @@ TW.block.lg(.hidden).rendered // "block lg:hidden" TW.md(.hover(.bg(.blue, .s700))).rendered // "md:hover:bg-blue-700" ``` +### Custom / arbitrary values + +For the occasional [arbitrary value](https://tailwindcss.com/docs/adding-custom-styles) +Tailwind v4 supports (square-bracket and CSS-variable notation), use the +type-safe `.custom(_:_:)` API — the utility prefix is supplied by you, only the +value is arbitrary: + +```swift +TW.custom("top", .value("117px")).rendered // "top-[117px]" +TW.custom("bg", .value("#bada55")).rendered // "bg-[#bada55]" +TW.custom("bg", .variable("--brand")).rendered // "bg-(--brand)" +TW.custom("grid-cols", .value("1fr 500px")).rendered +// "grid-cols-[1fr_500px]" (spaces become underscores) +TW.custom(property: "mask-type", value: "luminance").rendered +// "[mask-type:luminance]" +``` + ## Scope The modeled utility surface is intentionally **closed** — a set of Swift enums and methods — and grows component-driven as consumers (issue #67) need new -classes. For any class not yet modeled, the escape hatch is Plot's existing -`.class("…")`; TailwindKit itself never accepts raw strings. +classes. Related bare utilities are grouped into cohesive enum "sets" +(`Position`, `Flex`, `FlexDirection`, `ListStyle`, …) instead of a flat list of +properties. The only caller-supplied value strings TailwindKit accepts are +through the deliberate `.custom(…)` arbitrary-value API above (which still can't +emit a free-form class name). For any class not modeled at all, the escape hatch +is Plot's existing `.class("…")`. ## Testing diff --git a/Sources/TailwindKit/Core/DefaultTailwindClass.swift b/Sources/TailwindKit/Core/DefaultTailwindClass.swift new file mode 100644 index 0000000..ddeb260 --- /dev/null +++ b/Sources/TailwindKit/Core/DefaultTailwindClass.swift @@ -0,0 +1,43 @@ +// +// DefaultTailwindClass.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The built-in ``TailwindClass`` used by every modeled utility. +/// +/// A `public` type (it is the concrete value the capability witnesses pass to +/// the seam) with an `internal` initializer, so it is **not constructible by +/// name** outside the module — like ``DefaultColor``. This is +/// what keeps the public seam from becoming a raw-string entry point. +public struct DefaultTailwindClass: TailwindClass { + /// The rendered utility class, e.g. `"bg-blue-500"`. + public let className: String + + internal init(_ className: String) { + self.className = className + } +} diff --git a/Sources/TailwindKit/Core/DefaultVariant.swift b/Sources/TailwindKit/Core/DefaultVariant.swift new file mode 100644 index 0000000..288eec6 --- /dev/null +++ b/Sources/TailwindKit/Core/DefaultVariant.swift @@ -0,0 +1,94 @@ +// +// DefaultVariant.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The built-in Tailwind variant prefixes. +/// +/// A `public` type (the return type of the static members below) with an +/// `internal` initializer, so callers reference `.md`/`.hover` but never +/// construct it directly — like ``DefaultColor``. +public struct DefaultVariant: Variant { + // swiftlint:disable identifier_name + /// `sm:` — ≥ 40rem breakpoint. + public static let sm = DefaultVariant("sm") + /// `md:` — ≥ 48rem breakpoint. + public static let md = DefaultVariant("md") + /// `lg:` — ≥ 64rem breakpoint. + public static let lg = DefaultVariant("lg") + /// `xl:` — ≥ 80rem breakpoint. + public static let xl = DefaultVariant("xl") + // swiftlint:enable identifier_name + /// `2xl:` — ≥ 96rem breakpoint. + public static let xl2 = DefaultVariant("2xl") + /// `hover:` state variant. + public static let hover = DefaultVariant("hover") + /// `focus:` state variant. + public static let focus = DefaultVariant("focus") + /// `active:` state variant. + public static let active = DefaultVariant("active") + /// `disabled:` state variant. + public static let disabled = DefaultVariant("disabled") + /// `group-hover:` state variant. + public static let groupHover = DefaultVariant("group-hover") + /// `dark:` color-scheme variant. + public static let dark = DefaultVariant("dark") + + /// The rendered prefix, e.g. `"md"`, `"hover"`. + public let token: String + + internal init(_ token: String) { + self.token = token + } +} + +extension Variant where Self == DefaultVariant { + // swiftlint:disable identifier_name + /// `sm:` — ≥ 40rem breakpoint. + public static var sm: DefaultVariant { .sm } + /// `md:` — ≥ 48rem breakpoint. + public static var md: DefaultVariant { .md } + /// `lg:` — ≥ 64rem breakpoint. + public static var lg: DefaultVariant { .lg } + /// `xl:` — ≥ 80rem breakpoint. + public static var xl: DefaultVariant { .xl } + // swiftlint:enable identifier_name + /// `2xl:` — ≥ 96rem breakpoint. + public static var xl2: DefaultVariant { .xl2 } + /// `hover:` state variant. + public static var hover: DefaultVariant { .hover } + /// `focus:` state variant. + public static var focus: DefaultVariant { .focus } + /// `active:` state variant. + public static var active: DefaultVariant { .active } + /// `disabled:` state variant. + public static var disabled: DefaultVariant { .disabled } + /// `group-hover:` state variant. + public static var groupHover: DefaultVariant { .groupHover } + /// `dark:` color-scheme variant. + public static var dark: DefaultVariant { .dark } +} diff --git a/Sources/TailwindKit/Core/TW.swift b/Sources/TailwindKit/Core/TW.swift new file mode 100644 index 0000000..2549f63 --- /dev/null +++ b/Sources/TailwindKit/Core/TW.swift @@ -0,0 +1,34 @@ +// +// TW.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Convenient alias for ``TailwindStyleBuilder`` so chains can read `TW.flex.gap(4)`. +/// +/// The name is intentionally two characters — `TW` is the mandated public +/// spelling — so `type_name`'s minimum-length rule is disabled on this line. +public typealias TW = TailwindStyleBuilder // swiftlint:disable:this type_name diff --git a/Sources/TailwindKit/Core/TailwindClass.swift b/Sources/TailwindKit/Core/TailwindClass.swift new file mode 100644 index 0000000..6cb3a6f --- /dev/null +++ b/Sources/TailwindKit/Core/TailwindClass.swift @@ -0,0 +1,50 @@ +// +// TailwindClass.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A fully-formed Tailwind utility class fragment — the whole `"bg-blue-500"`, +/// `"flex"`, or `"md:hover:gap-4"` that ``TailwindStyleBuilder`` appends to its token +/// list. +/// +/// `TailwindClass` is the **seam value** the public builder primitive +/// ``TailwindStyleBuilder/appending(_:)`` accepts. It exists so the seam can be +/// `public` (required for the capability-protocol architecture — see +/// ``TailwindStyle``) *without* the public API ever accepting a raw +/// `String`: the built-in utilities construct a ``DefaultTailwindClass``, +/// whose initializer is `internal`, so a caller cannot mint one from an +/// arbitrary string. This preserves TailwindKit's invariant that the modeled +/// surface is closed and type-safe. +/// +/// Unlike ``TailwindToken`` (a *value fragment* like `blue`/`500`), a +/// `TailwindClass` is an *entire* utility class. A downstream module can still +/// conform its own type as a deliberate escape hatch, but the ordinary path is +/// the leading-dot fluent members. +public protocol TailwindClass { + /// The rendered utility class, e.g. `"bg-blue-500"`. + var className: String { get } +} diff --git a/Sources/TailwindKit/Core/TailwindStyle.swift b/Sources/TailwindKit/Core/TailwindStyle.swift new file mode 100644 index 0000000..009dc06 --- /dev/null +++ b/Sources/TailwindKit/Core/TailwindStyle.swift @@ -0,0 +1,64 @@ +// +// TailwindStyle.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The shared **seam** the TailwindKit capability protocols implement against. +/// +/// Each capability (``ColorStyling``, ``SpacingStyling``, …) provides its fluent +/// members in a protocol extension constrained on `Self: TailwindStyle`, +/// composing new styles through the two primitives declared here, so +/// ``TailwindStyleBuilder`` itself is only storage plus `init`/``TailwindStyleBuilder/rendered`` +/// — it carries no fluent method bodies. This mirrors how `ButtondownKit`'s +/// capability protocols implement against `UnderlyingClientProtocol.underlying`. +/// +/// This protocol must be **public**: the capability protocols' members are +/// public requirements witnessed by those constrained extensions, and Swift +/// forbids a `public` member in an extension whose generic constraint refers to +/// a non-public protocol ("cannot declare a public instance method in an +/// extension with internal requirements"). +/// +/// Crucially — and unlike a naive port of the `underlying` seam — neither +/// primitive accepts a raw `String`. ``appending(_:)`` takes a ``TailwindClass`` +/// and ``prefixing(_:_:)`` takes a ``Variant``, both of whose built-in +/// conformers have `internal` initializers. So exposing the seam publicly does +/// **not** open a raw-string hole in the API; the modeled surface stays closed. +public protocol TailwindStyle { + /// Returns a new style with `tailwindClass` appended. + /// + /// The single composition primitive behind every non-variant utility. Built-in + /// utilities pass a ``DefaultTailwindClass``; a downstream module may pass its + /// own ``TailwindClass`` conformer. + func appending(_ tailwindClass: some TailwindClass) -> Self + + /// Returns a new style with every token of `other` prefixed by `variant`. + /// + /// Models responsive/state variants; prefixes stack, so + /// `.md(.hover(.bg(.blue, .s700)))` renders `"md:hover:bg-blue-700"`. + func prefixing(_ variant: some Variant, _ other: TailwindStyleBuilder) + -> Self +} diff --git a/Sources/TailwindKit/TailwindStyle.swift b/Sources/TailwindKit/Core/TailwindStyleBuilder.swift similarity index 65% rename from Sources/TailwindKit/TailwindStyle.swift rename to Sources/TailwindKit/Core/TailwindStyleBuilder.swift index 89ba7d1..9a85728 100644 --- a/Sources/TailwindKit/TailwindStyle.swift +++ b/Sources/TailwindKit/Core/TailwindStyleBuilder.swift @@ -1,5 +1,5 @@ // -// TailwindStyle.swift +// TailwindStyleBuilder.swift // TailwindKit // // Created by Leo Dion. @@ -30,10 +30,10 @@ /// A type-safe, fluent builder for [Tailwind CSS v4](https://tailwindcss.com) /// utility class strings. /// -/// `TailwindStyle` is a pure value type with **no dependency on Plot** (or any +/// `TailwindStyleBuilder` is a pure value type with **no dependency on Plot** (or any /// HTML library). Every member — bare utilities exposed as computed properties /// and parameterized utilities exposed as methods — returns a new -/// `TailwindStyle`, so styles are composed by chaining: +/// `TailwindStyleBuilder`, so styles are composed by chaining: /// /// ```swift /// TW.flex.items(.center).gap(4).bg(.blue, .s500).rendered @@ -50,9 +50,9 @@ /// /// The set of modeled utilities is intentionally **closed** and grown /// component-driven: add cases as components need them. For any class not yet -/// modeled, the escape hatch is Plot's existing `.class("…")` — `TailwindStyle` +/// modeled, the escape hatch is Plot's existing `.class("…")` — `TailwindStyleBuilder` /// itself never accepts raw strings. -public struct TailwindStyle: Sendable, Equatable, Hashable { +public struct TailwindStyleBuilder: Sendable, Equatable, Hashable { /// The ordered, fully-prefixed utility tokens (e.g. `"items-center"`, /// `"md:gap-4"`), rendered space-separated by ``rendered``. private let tokens: [String] @@ -78,9 +78,13 @@ public struct TailwindStyle: Sendable, Equatable, Hashable { self.tokens = tokens } - /// Returns a new style with `token` appended. - internal func appending(_ token: String) -> TailwindStyle { - TailwindStyle(tokens: tokens + [token]) + /// Returns a new style with the raw token string `token` appended. + /// + /// File-private: the only raw-string composition point. The public seam + /// (``appending(_:)`` taking a ``TailwindClass``) forwards here, so no + /// raw-string entry point is ever exposed. + private func appendingToken(_ token: String) -> TailwindStyleBuilder { + TailwindStyleBuilder(tokens: tokens + [token]) } /// Returns a new style with every token of `other` prefixed by @@ -88,7 +92,25 @@ public struct TailwindStyle: Sendable, Equatable, Hashable { /// /// Used to model responsive/state variants (e.g. `md`, `hover`); prefixes /// stack, so `.md(.hover(.bg(.blue, .s700)))` renders `"md:hover:bg-blue-700"`. - internal func prefixing(_ prefix: String, _ other: TailwindStyle) -> TailwindStyle { - TailwindStyle(tokens: tokens + other.tokens.map { "\(prefix):\($0)" }) + private func prefixingToken(_ prefix: String, _ other: TailwindStyleBuilder) + -> TailwindStyleBuilder + { + TailwindStyleBuilder(tokens: tokens + other.tokens.map { "\(prefix):\($0)" }) + } +} + +// The public seam (see ``TailwindStyle``). Both primitives forward to the +// file-private string helpers above; retyping the *public* surface to +// ``TailwindClass`` / ``Variant`` is what lets the seam be public without ever +// accepting a raw `String`. +extension TailwindStyleBuilder: TailwindStyle { + /// Returns a new style with `tailwindClass` appended. + public func appending(_ tailwindClass: some TailwindClass) -> Self { + appendingToken(tailwindClass.className) + } + + /// Returns a new style with every token of `other` prefixed by `variant`. + public func prefixing(_ variant: some Variant, _ other: TailwindStyleBuilder) -> Self { + prefixingToken(variant.token, other) } } diff --git a/Sources/TailwindKit/Core/TailwindToken.swift b/Sources/TailwindKit/Core/TailwindToken.swift new file mode 100644 index 0000000..4790413 --- /dev/null +++ b/Sources/TailwindKit/Core/TailwindToken.swift @@ -0,0 +1,63 @@ +// +// TailwindToken.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A single value fragment in a Tailwind utility class — the `blue` in +/// `bg-blue-500`, the `lg` in `rounded-lg`, the `4` in `gap-4`. +/// +/// `TailwindToken` is the shared seam for Tailwind v4's **theme-scale** value +/// families (the `@theme` namespaces `--color-*`, `--text-*`, `--radius-*`, …). +/// Each such family is modeled as a protocol refining `TailwindToken`, with the +/// documented built-in values exposed as static members on a `Default…` +/// conforming type — mirroring how SwiftUI models `ButtonStyle`, `TextFieldStyle`, +/// etc. Because these scales are extensible in Tailwind (you add values via +/// `@theme`), the Swift model is open too: conform your own type to add a custom +/// color, size, radius, and so on. +/// +/// ```swift +/// // Built-in, via the leading-dot static member: +/// TW.bg(.blue, .s500).rendered // "bg-blue-500" +/// +/// // Custom, by conforming your own token type in your module: +/// struct BrandColor: Color { let token = "brand" } +/// extension Color where Self == BrandColor { +/// static var brand: BrandColor { .init() } +/// } +/// TW.bg(.brand, .s500).rendered // "bg-brand-500" +/// ``` +/// +/// Tailwind's **fixed keyword sets** (position, object-fit, flex-direction, the +/// color *shade* steps, …) are *not* modeled with this protocol — a custom value +/// there is not a coherent concept, so they remain closed enums. +public protocol TailwindToken: Sendable { + /// The rendered fragment, e.g. `"blue"`, `"lg"`, `"4"`. + /// + /// Consumed by ``TailwindStyleBuilder`` builder methods when composing a class + /// string; not intended to be read directly by callers. + var token: String { get } +} diff --git a/Sources/TailwindKit/Core/Variant.swift b/Sources/TailwindKit/Core/Variant.swift new file mode 100644 index 0000000..e70a588 --- /dev/null +++ b/Sources/TailwindKit/Core/Variant.swift @@ -0,0 +1,49 @@ +// +// Variant.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A Tailwind **variant** prefix — the `md` in `md:flex`, the `hover` in +/// `hover:bg-blue-700`. +/// +/// `Variant` is the value passed to ``TailwindStyleBuilder/prefixing(_:_:)`` and +/// exposed through the responsive/state fluent members (`.md`, `.hover`, …). +/// Variants compose by nesting — `.md(.hover(.bg(.blue, .s700)))` stacks the +/// prefixes into `"md:hover:bg-blue-700"`. +/// +/// Like ``Color``, `Variant` is **extensible** (Tailwind lets +/// you register custom variants, e.g. via `@custom-variant`): conform your own +/// type to add one — +/// +/// ```swift +/// struct SupportsGrid: Variant { let token = "supports-[display:grid]" } +/// TW().prefixing(SupportsGrid(), .flex) // "supports-[display:grid]:flex" +/// ``` +/// +/// The documented built-ins are exposed as static members on +/// ``DefaultVariant`` via the leading-dot syntax (`.md`, …). +public protocol Variant: TailwindToken {} diff --git a/Sources/TailwindKit/Node+Tailwind.swift b/Sources/TailwindKit/Plot/Node+Tailwind.swift similarity index 81% rename from Sources/TailwindKit/Node+Tailwind.swift rename to Sources/TailwindKit/Plot/Node+Tailwind.swift index 29dc23c..4db830f 100644 --- a/Sources/TailwindKit/Node+Tailwind.swift +++ b/Sources/TailwindKit/Plot/Node+Tailwind.swift @@ -30,7 +30,7 @@ import Plot extension Node where Context: HTMLContext { - /// Render a ``TailwindStyle`` into this element's `class` attribute. + /// Render a ``TailwindStyleBuilder`` into this element's `class` attribute. /// /// Sugar for `.class(style.rendered)`: /// @@ -39,18 +39,18 @@ extension Node where Context: HTMLContext { /// //
/// ``` /// - /// For any class not modeled by ``TailwindStyle``, use Plot's existing + /// For any class not modeled by ``TailwindStyleBuilder``, use Plot's existing /// `.class("…")` directly. - public static func tailwind(_ style: TailwindStyle) -> Node { + public static func tailwind(_ style: TailwindStyleBuilder) -> Node { .class(style.rendered) } } extension Attribute where Context: HTMLContext { - /// Render a ``TailwindStyle`` into this element's `class` attribute. + /// Render a ``TailwindStyleBuilder`` into this element's `class` attribute. /// /// Sugar for `.class(style.rendered)`. - public static func tailwind(_ style: TailwindStyle) -> Attribute { + public static func tailwind(_ style: TailwindStyleBuilder) -> Attribute { .class(style.rendered) } } diff --git a/Sources/TailwindKit/Styling/ArbitraryStyling.swift b/Sources/TailwindKit/Styling/ArbitraryStyling.swift new file mode 100644 index 0000000..6896ffc --- /dev/null +++ b/Sources/TailwindKit/Styling/ArbitraryStyling.swift @@ -0,0 +1,92 @@ +// +// ArbitraryStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The **arbitrary value** escape hatch utilities. +/// +/// A capability protocol whose members are witnessed against the +/// ``TailwindStyle`` seam; ``TailwindStyleBuilder`` conforms to it. See +/// ``TailwindStyle`` for why the surface is organized this way. +/// +/// These members intentionally take raw `String` arguments: they are the +/// escape hatch for expressing Tailwind classes that have no strongly typed +/// counterpart. Spaces in values are escaped to underscores per Tailwind's +/// arbitrary-value syntax. +public protocol ArbitraryStyling { + /// `-[]`, e.g. `.arbitrary("top", value: "117px")` → `top-[117px]`. + /// + /// Spaces in `value` are escaped to underscores. + func arbitrary(_ prefix: String, value: String) -> Self + /// `-()`, e.g. `.arbitrary("bg", variable: "--brand")` → `bg-(--brand)`. + func arbitrary(_ prefix: String, variable name: String) -> Self + /// `[:]`, e.g. `.custom(property: "mask-type", value: "luminance")`. + /// + /// Spaces in `value` are escaped to underscores. + func custom(property: String, value: String) -> Self +} + +extension ArbitraryStyling where Self: TailwindStyle { + private func escapingSpaces(_ value: String) -> String { + value.replacingOccurrences(of: " ", with: "_") + } + + /// `-[]`, e.g. `.arbitrary("top", value: "117px")` → `top-[117px]`. + /// + /// Spaces in `value` are escaped to underscores. + public func arbitrary(_ prefix: String, value: String) -> Self { + appending(DefaultTailwindClass("\(prefix)-[\(escapingSpaces(value))]")) + } + /// `-()`, e.g. `.arbitrary("bg", variable: "--brand")` → `bg-(--brand)`. + public func arbitrary(_ prefix: String, variable name: String) -> Self { + appending(DefaultTailwindClass("\(prefix)-(\(name))")) + } + /// `[:]`, e.g. `.custom(property: "mask-type", value: "luminance")`. + /// + /// Spaces in `value` are escaped to underscores. + public func custom(property: String, value: String) -> Self { + appending(DefaultTailwindClass("[\(property):\(escapingSpaces(value))]")) + } +} + +extension TailwindStyleBuilder: ArbitraryStyling {} + +// Static mirrors so an arbitrary utility can start a chain with a leading dot. +extension TailwindStyleBuilder { + /// `-[]`. + public static func arbitrary(_ prefix: String, value: String) -> TailwindStyleBuilder { + TailwindStyleBuilder().arbitrary(prefix, value: value) + } + /// `-()`. + public static func arbitrary(_ prefix: String, variable name: String) -> TailwindStyleBuilder { + TailwindStyleBuilder().arbitrary(prefix, variable: name) + } + /// `[:]`. + public static func custom(property: String, value: String) -> TailwindStyleBuilder { + TailwindStyleBuilder().custom(property: property, value: value) + } +} diff --git a/Sources/TailwindKit/Styling/BorderStyling.swift b/Sources/TailwindKit/Styling/BorderStyling.swift new file mode 100644 index 0000000..efe61e6 --- /dev/null +++ b/Sources/TailwindKit/Styling/BorderStyling.swift @@ -0,0 +1,106 @@ +// +// BorderStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The **border** and **border-radius** utilities. +/// +/// A capability protocol whose members are witnessed against the +/// ``TailwindStyle`` seam; ``TailwindStyleBuilder`` conforms to it. See +/// ``TailwindStyle`` for why the surface is organized this way. +public protocol BorderStyling { + /// `border`. + var border: Self { get } + /// `rounded`. + var rounded: Self { get } + /// `border-none`. + var borderNone: Self { get } + + /// `border-`, e.g. `.border(2)` → `border-2`. + func border(_ width: Int) -> Self + /// `border--`, e.g. `.border(.t, 2)` → `border-t-2`. + func border(_ side: BorderSide, _ width: Int) -> Self + /// `rounded-`, e.g. `.rounded(.lg)` → `rounded-lg`. + func rounded(_ radius: some Radius) -> Self +} + +extension BorderStyling where Self: TailwindStyle { + // MARK: Bare + + /// `border`. + public var border: Self { appending(DefaultTailwindClass("border")) } + /// `rounded`. + public var rounded: Self { appending(DefaultTailwindClass("rounded")) } + /// `border-none`. + public var borderNone: Self { + appending(DefaultTailwindClass("border-none")) + } + + // MARK: Parameterized + + /// `border-`, e.g. `.border(2)` → `border-2`. + public func border(_ width: Int) -> Self { + appending(DefaultTailwindClass("border-\(width)")) + } + /// `border--`, e.g. `.border(.t, 2)` → `border-t-2`. + public func border(_ side: BorderSide, _ width: Int) -> Self { + appending(DefaultTailwindClass("border-\(side.token)-\(width)")) + } + /// `rounded-`, e.g. `.rounded(.lg)` → `rounded-lg`. + public func rounded(_ radius: some Radius) -> Self { + radius.token.isEmpty + ? appending(DefaultTailwindClass("rounded")) + : appending(DefaultTailwindClass("rounded-\(radius.token)")) + } +} + +extension TailwindStyleBuilder: BorderStyling {} + +// Static mirrors so a border utility can start a chain with a leading dot. +extension TailwindStyleBuilder { + /// `border`. + public static var border: TailwindStyleBuilder { TailwindStyleBuilder().border } + /// `rounded`. + public static var rounded: TailwindStyleBuilder { TailwindStyleBuilder().rounded } + /// `border-none`. + public static var borderNone: TailwindStyleBuilder { TailwindStyleBuilder().borderNone } +} + +extension TailwindStyleBuilder { + /// `border-`. + public static func border(_ width: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().border(width) + } + /// `border--`. + public static func border(_ side: BorderSide, _ width: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().border(side, width) + } + /// `rounded-`. + public static func rounded(_ radius: some Radius) -> TailwindStyleBuilder { + TailwindStyleBuilder().rounded(radius) + } +} diff --git a/Sources/TailwindKit/Styling/ColorStyling.swift b/Sources/TailwindKit/Styling/ColorStyling.swift new file mode 100644 index 0000000..c67acfa --- /dev/null +++ b/Sources/TailwindKit/Styling/ColorStyling.swift @@ -0,0 +1,178 @@ +// +// ColorStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The background/text/border **color** utilities. +/// +/// A capability protocol whose members are witnessed against the +/// ``TailwindStyle`` seam; ``TailwindStyleBuilder`` conforms to it. See +/// ``TailwindStyle`` for why the surface is organized this way. +public protocol ColorStyling { + /// `bg-white`. + var bgWhite: Self { get } + /// `bg-black`. + var bgBlack: Self { get } + /// `bg-transparent`. + var bgTransparent: Self { get } + + /// `bg--`, e.g. `.bg(.blue, .s500)`. + func bg(_ color: some Color, _ shade: Shade) -> Self + /// `border--`, e.g. `.borderColor(.gray, .s200)`. + func borderColor(_ color: some Color, _ shade: Shade) -> Self + /// `text--`, e.g. `.text(.blue, .s500)`. + func text(_ color: some Color, _ shade: Shade) -> Self + + /// `bg-white/`, e.g. `.bgWhite(opacity: 90)` → `bg-white/90`. + func bgWhite(opacity: Int) -> Self + /// `bg-black/`, e.g. `.bgBlack(opacity: 30)` → `bg-black/30`. + func bgBlack(opacity: Int) -> Self + /// `bg--/`, e.g. `.bg(.gray, .s500, opacity: 50)`. + func bg(_ color: some Color, _ shade: Shade, opacity: Int) + -> Self + /// `text--/`. + func text(_ color: some Color, _ shade: Shade, opacity: Int) + -> Self + /// `border--/`, e.g. `.borderColor(.gray, .s400, opacity: 10)`. + func borderColor(_ color: some Color, _ shade: Shade, opacity: Int) + -> Self +} + +extension ColorStyling where Self: TailwindStyle { + // MARK: Bare + + /// `bg-white`. + public var bgWhite: Self { appending(DefaultTailwindClass("bg-white")) } + /// `bg-black`. + public var bgBlack: Self { appending(DefaultTailwindClass("bg-black")) } + /// `bg-transparent`. + public var bgTransparent: Self { + appending(DefaultTailwindClass("bg-transparent")) + } + + // MARK: Color + shade + + /// `bg--`, e.g. `.bg(.blue, .s500)`. + public func bg(_ color: some Color, _ shade: Shade) -> Self { + appending(DefaultTailwindClass("bg-\(color.token)-\(shade.token)")) + } + /// `border--`, e.g. `.borderColor(.gray, .s200)`. + public func borderColor(_ color: some Color, _ shade: Shade) + -> Self + { + appending(DefaultTailwindClass("border-\(color.token)-\(shade.token)")) + } + /// `text--`, e.g. `.text(.blue, .s500)`. + public func text(_ color: some Color, _ shade: Shade) -> Self { + appending(DefaultTailwindClass("text-\(color.token)-\(shade.token)")) + } + + // MARK: Color + shade + opacity + + /// `bg-white/`, e.g. `.bgWhite(opacity: 90)` → `bg-white/90`. + public func bgWhite(opacity: Int) -> Self { + appending(DefaultTailwindClass("bg-white/\(opacity)")) + } + /// `bg-black/`, e.g. `.bgBlack(opacity: 30)` → `bg-black/30`. + public func bgBlack(opacity: Int) -> Self { + appending(DefaultTailwindClass("bg-black/\(opacity)")) + } + /// `bg--/`, e.g. `.bg(.gray, .s500, opacity: 50)`. + public func bg(_ color: some Color, _ shade: Shade, opacity: Int) + -> Self + { + appending( + DefaultTailwindClass("bg-\(color.token)-\(shade.token)/\(opacity)") + ) + } + /// `text--/`. + public func text(_ color: some Color, _ shade: Shade, opacity: Int) + -> Self + { + appending( + DefaultTailwindClass("text-\(color.token)-\(shade.token)/\(opacity)") + ) + } + /// `border--/`, e.g. `.borderColor(.gray, .s400, opacity: 10)`. + public func borderColor( + _ color: some Color, _ shade: Shade, opacity: Int + ) -> Self { + appending( + DefaultTailwindClass("border-\(color.token)-\(shade.token)/\(opacity)") + ) + } +} + +extension TailwindStyleBuilder: ColorStyling {} + +// Static mirrors so a color utility can start a chain with a leading dot. +extension TailwindStyleBuilder { + /// `bg-white`. + public static var bgWhite: TailwindStyleBuilder { TailwindStyleBuilder().bgWhite } + /// `bg-black`. + public static var bgBlack: TailwindStyleBuilder { TailwindStyleBuilder().bgBlack } + /// `bg-transparent`. + public static var bgTransparent: TailwindStyleBuilder { TailwindStyleBuilder().bgTransparent } +} + +extension TailwindStyleBuilder { + /// `bg--`. + public static func bg(_ color: some Color, _ shade: Shade) -> TailwindStyleBuilder { + TailwindStyleBuilder().bg(color, shade) + } + /// `border--`. + public static func borderColor(_ color: some Color, _ shade: Shade) -> TailwindStyleBuilder { + TailwindStyleBuilder().borderColor(color, shade) + } + /// `text--`. + public static func text(_ color: some Color, _ shade: Shade) -> TailwindStyleBuilder { + TailwindStyleBuilder().text(color, shade) + } + /// `bg-white/`. + public static func bgWhite(opacity: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().bgWhite(opacity: opacity) + } + /// `bg-black/`. + public static func bgBlack(opacity: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().bgBlack(opacity: opacity) + } + /// `bg--/`. + public static func bg(_ color: some Color, _ shade: Shade, opacity: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().bg(color, shade, opacity: opacity) + } + /// `text--/`. + public static func text(_ color: some Color, _ shade: Shade, opacity: Int) -> TailwindStyleBuilder + { + TailwindStyleBuilder().text(color, shade, opacity: opacity) + } + /// `border--/`. + public static func borderColor(_ color: some Color, _ shade: Shade, opacity: Int) + -> TailwindStyleBuilder + { + TailwindStyleBuilder().borderColor(color, shade, opacity: opacity) + } +} diff --git a/Sources/TailwindKit/Styling/DisplayStyling.swift b/Sources/TailwindKit/Styling/DisplayStyling.swift new file mode 100644 index 0000000..07de2d5 --- /dev/null +++ b/Sources/TailwindKit/Styling/DisplayStyling.swift @@ -0,0 +1,105 @@ +// +// DisplayStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The **display** utilities. +/// +/// A capability protocol whose members are witnessed against the +/// ``TailwindStyle`` seam; ``TailwindStyleBuilder`` conforms to it. See +/// ``TailwindStyle`` for why the surface is organized this way. +public protocol DisplayStyling { + /// `flex`. + var flex: Self { get } + /// `inline-flex`. + var inlineFlex: Self { get } + /// `grid`. + var grid: Self { get } + /// `block`. + var block: Self { get } + /// `inline-block`. + var inlineBlock: Self { get } + /// `inline`. + var inline: Self { get } + /// `hidden`. + var hidden: Self { get } + /// `container`. + var container: Self { get } + /// `contents`. + var contents: Self { get } +} + +extension DisplayStyling where Self: TailwindStyle { + // MARK: Bare + + /// `flex`. + public var flex: Self { appending(DefaultTailwindClass("flex")) } + /// `inline-flex`. + public var inlineFlex: Self { + appending(DefaultTailwindClass("inline-flex")) + } + /// `grid`. + public var grid: Self { appending(DefaultTailwindClass("grid")) } + /// `block`. + public var block: Self { appending(DefaultTailwindClass("block")) } + /// `inline-block`. + public var inlineBlock: Self { + appending(DefaultTailwindClass("inline-block")) + } + /// `inline`. + public var inline: Self { appending(DefaultTailwindClass("inline")) } + /// `hidden`. + public var hidden: Self { appending(DefaultTailwindClass("hidden")) } + /// `container`. + public var container: Self { appending(DefaultTailwindClass("container")) } + /// `contents`. + public var contents: Self { appending(DefaultTailwindClass("contents")) } +} + +extension TailwindStyleBuilder: DisplayStyling {} + +// Static mirrors so a display utility can start a chain with a leading dot. +extension TailwindStyleBuilder { + /// `flex`. + public static var flex: TailwindStyleBuilder { TailwindStyleBuilder().flex } + /// `inline-flex`. + public static var inlineFlex: TailwindStyleBuilder { TailwindStyleBuilder().inlineFlex } + /// `grid`. + public static var grid: TailwindStyleBuilder { TailwindStyleBuilder().grid } + /// `block`. + public static var block: TailwindStyleBuilder { TailwindStyleBuilder().block } + /// `inline-block`. + public static var inlineBlock: TailwindStyleBuilder { TailwindStyleBuilder().inlineBlock } + /// `inline`. + public static var inline: TailwindStyleBuilder { TailwindStyleBuilder().inline } + /// `hidden`. + public static var hidden: TailwindStyleBuilder { TailwindStyleBuilder().hidden } + /// `container`. + public static var container: TailwindStyleBuilder { TailwindStyleBuilder().container } + /// `contents`. + public static var contents: TailwindStyleBuilder { TailwindStyleBuilder().contents } +} diff --git a/Sources/TailwindKit/Styling/EffectsStyling.swift b/Sources/TailwindKit/Styling/EffectsStyling.swift new file mode 100644 index 0000000..bd994ec --- /dev/null +++ b/Sources/TailwindKit/Styling/EffectsStyling.swift @@ -0,0 +1,161 @@ +// +// EffectsStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Shadow, ring, opacity, object-fit, and filter utilities. +/// +/// A capability protocol witnessed against the ``TailwindStyle`` seam; +/// ``TailwindStyleBuilder`` conforms to it. See ``TailwindStyle`` for the +/// architecture rationale. +public protocol EffectsStyling { + /// `invert` (`invert(100%)`). + var invert: Self { get } + /// `grayscale` (`grayscale(100%)`). + var grayscale: Self { get } + /// `backdrop-grayscale`. + var backdropGrayscale: Self { get } + + /// `shadow-`, e.g. `.shadow(.lg)`. + func shadow(_ shadow: some Shadow) -> Self + /// `drop-shadow-`, e.g. `.dropShadow(.xl)`. + func dropShadow(_ shadow: some DropShadow) -> Self + /// `ring-`, e.g. `.ring(4)`. + func ring(_ width: Int) -> Self + /// `opacity-`, e.g. `.opacity(90)`. + func opacity(_ value: Int) -> Self + /// `object-`, e.g. `.object(.cover)`. + func object(_ fit: ObjectFit) -> Self + /// `backdrop-blur-`, e.g. `.backdropBlur(.lg)`. + func backdropBlur(_ radius: some Radius) -> Self + /// `backdrop-brightness-`, e.g. `.backdropBrightness(50)`. + func backdropBrightness(_ value: Int) -> Self + /// `hue-rotate-`, e.g. `.hueRotate(180)`. + func hueRotate(_ degrees: Int) -> Self +} + +extension EffectsStyling where Self: TailwindStyle { + // MARK: Filters (bare) + + /// `invert` (`invert(100%)`). + public var invert: Self { appending(DefaultTailwindClass("invert")) } + /// `grayscale` (`grayscale(100%)`). + public var grayscale: Self { appending(DefaultTailwindClass("grayscale")) } + /// `backdrop-grayscale`. + public var backdropGrayscale: Self { + appending(DefaultTailwindClass("backdrop-grayscale")) + } + + // MARK: Effects + + /// `shadow-`, e.g. `.shadow(.lg)`. + public func shadow(_ shadow: some Shadow) -> Self { + appending(DefaultTailwindClass("shadow-\(shadow.token)")) + } + /// `drop-shadow-`, e.g. `.dropShadow(.xl)`. + public func dropShadow(_ shadow: some DropShadow) -> Self { + appending(DefaultTailwindClass("drop-shadow-\(shadow.token)")) + } + /// `ring-`, e.g. `.ring(4)`. + public func ring(_ width: Int) -> Self { + appending(DefaultTailwindClass("ring-\(width)")) + } + /// `opacity-`, e.g. `.opacity(90)`. + public func opacity(_ value: Int) -> Self { + appending(DefaultTailwindClass("opacity-\(value)")) + } + /// `object-`, e.g. `.object(.cover)`. + public func object(_ fit: ObjectFit) -> Self { + appending(DefaultTailwindClass("object-\(fit.token)")) + } + + // MARK: Filters (parameterized) + + /// `backdrop-blur-`, e.g. `.backdropBlur(.lg)`. + public func backdropBlur(_ radius: some Radius) -> Self { + radius.token.isEmpty + ? appending(DefaultTailwindClass("backdrop-blur")) + : appending(DefaultTailwindClass("backdrop-blur-\(radius.token)")) + } + /// `backdrop-brightness-`, e.g. `.backdropBrightness(50)`. + public func backdropBrightness(_ value: Int) -> Self { + appending(DefaultTailwindClass("backdrop-brightness-\(value)")) + } + /// `hue-rotate-`, e.g. `.hueRotate(180)`. + public func hueRotate(_ degrees: Int) -> Self { + appending(DefaultTailwindClass("hue-rotate-\(degrees)")) + } +} + +extension TailwindStyleBuilder: EffectsStyling {} + +// Static mirrors so an effect/filter utility can start a chain with a leading dot. +extension TailwindStyleBuilder { + /// `invert`. + public static var invert: TailwindStyleBuilder { TailwindStyleBuilder().invert } + /// `grayscale`. + public static var grayscale: TailwindStyleBuilder { TailwindStyleBuilder().grayscale } + /// `backdrop-grayscale`. + public static var backdropGrayscale: TailwindStyleBuilder { + TailwindStyleBuilder().backdropGrayscale + } +} + +extension TailwindStyleBuilder { + /// `shadow-`. + public static func shadow(_ shadow: some Shadow) -> TailwindStyleBuilder { + TailwindStyleBuilder().shadow(shadow) + } + /// `drop-shadow-`. + public static func dropShadow(_ shadow: some DropShadow) -> TailwindStyleBuilder { + TailwindStyleBuilder().dropShadow(shadow) + } + /// `ring-`. + public static func ring(_ width: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().ring(width) + } + /// `opacity-`. + public static func opacity(_ value: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().opacity(value) + } + /// `object-`. + public static func object(_ fit: ObjectFit) -> TailwindStyleBuilder { + TailwindStyleBuilder().object(fit) + } + /// `backdrop-blur-`. + public static func backdropBlur(_ radius: some Radius) -> TailwindStyleBuilder { + TailwindStyleBuilder().backdropBlur(radius) + } + /// `backdrop-brightness-`. + public static func backdropBrightness(_ value: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().backdropBrightness(value) + } + /// `hue-rotate-`. + public static func hueRotate(_ degrees: Int) -> TailwindStyleBuilder { + TailwindStyleBuilder().hueRotate(degrees) + } +} diff --git a/Sources/TailwindKit/Styling/FlexGridStyling.swift b/Sources/TailwindKit/Styling/FlexGridStyling.swift new file mode 100644 index 0000000..e472d36 --- /dev/null +++ b/Sources/TailwindKit/Styling/FlexGridStyling.swift @@ -0,0 +1,168 @@ +// +// FlexGridStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Flexbox & grid layout utilities — direction, wrapping, growth, gaps, and +/// item/content alignment. +/// +/// A capability protocol witnessed against the ``TailwindStyle`` seam; +/// ``TailwindStyleBuilder`` conforms to it. See ``TailwindStyle`` for the +/// architecture rationale. +public protocol FlexGridStyling { + /// `flex-wrap`. + var flexWrap: Self { get } + /// `grow`. + var grow: Self { get } + /// `shrink`. + var shrink: Self { get } + /// `grow-0`. + var grow0: Self { get } + /// `shrink-0`. + var shrink0: Self { get } + /// `gap` (bare). + var gap: Self { get } + + /// `items-` — cross-axis alignment, e.g. `.items(.center)`. + func items(_ align: Align) -> Self + /// `justify-` — main-axis distribution, e.g. `.justify(.between)`. + func justify(_ value: Justify) -> Self + /// `grid-cols-`, e.g. `.gridCols(3)`. + func gridCols(_ count: Int) -> Self + /// `gap-`, e.g. `.gap(4)`. + func gap(_ amount: DefaultSpacing) -> Self + /// `gap-x-`. + func gapX(_ amount: DefaultSpacing) -> Self + /// `gap-y-`. + func gapY(_ amount: DefaultSpacing) -> Self + /// `flex-` shorthand, e.g. `.flex(.one)` → `flex-1`. + func flex(_ value: Flex) -> Self + /// `flex-`, e.g. `.flexDirection(.col)` → `flex-col`. + func flexDirection(_ value: FlexDirection) -> Self + /// `self-`, e.g. `.selfAlign(.start)` → `self-start`. + func selfAlign(_ align: Align) -> Self + /// `justify-items-`, e.g. `.justifyItems(.end)`. + func justifyItems(_ align: Align) -> Self + /// `justify-self-`, e.g. `.justifySelf(.center)`. + func justifySelf(_ align: Align) -> Self + /// `content-`, e.g. `.content(.between)` → `content-between`. + func content(_ value: Justify) -> Self + /// `place-items-`, e.g. `.placeItems(.stretch)`. + func placeItems(_ align: Align) -> Self + /// `place-self-`, e.g. `.placeSelf(.center)`. + func placeSelf(_ align: Align) -> Self + /// `space-x-`, e.g. `.spaceX(1)`. + func spaceX(_ amount: DefaultSpacing) -> Self + /// `space-y-`. + func spaceY(_ amount: DefaultSpacing) -> Self +} + +extension FlexGridStyling where Self: TailwindStyle { + // MARK: Bare + + /// `flex-wrap`. + public var flexWrap: Self { appending(DefaultTailwindClass("flex-wrap")) } + /// `grow`. + public var grow: Self { appending(DefaultTailwindClass("grow")) } + /// `shrink`. + public var shrink: Self { appending(DefaultTailwindClass("shrink")) } + /// `grow-0`. + public var grow0: Self { appending(DefaultTailwindClass("grow-0")) } + /// `shrink-0`. + public var shrink0: Self { appending(DefaultTailwindClass("shrink-0")) } + /// `gap` (bare). + public var gap: Self { appending(DefaultTailwindClass("gap")) } + + // MARK: Flex & grid + + /// `items-` — cross-axis alignment, e.g. `.items(.center)`. + public func items(_ align: Align) -> Self { + appending(DefaultTailwindClass("items-\(align.token)")) + } + /// `justify-` — main-axis distribution, e.g. `.justify(.between)`. + public func justify(_ value: Justify) -> Self { + appending(DefaultTailwindClass("justify-\(value.token)")) + } + /// `grid-cols-`, e.g. `.gridCols(3)`. + public func gridCols(_ count: Int) -> Self { + appending(DefaultTailwindClass("grid-cols-\(count)")) + } + /// `gap-`, e.g. `.gap(4)`. + public func gap(_ amount: DefaultSpacing) -> Self { + appending(DefaultTailwindClass("gap-\(amount.token)")) + } + /// `gap-x-`. + public func gapX(_ amount: DefaultSpacing) -> Self { + appending(DefaultTailwindClass("gap-x-\(amount.token)")) + } + /// `gap-y-`. + public func gapY(_ amount: DefaultSpacing) -> Self { + appending(DefaultTailwindClass("gap-y-\(amount.token)")) + } + /// `flex-` shorthand, e.g. `.flex(.one)` → `flex-1`. + public func flex(_ value: Flex) -> Self { + appending(DefaultTailwindClass("flex-\(value.token)")) + } + /// `flex-`, e.g. `.flexDirection(.col)` → `flex-col`. + public func flexDirection(_ value: FlexDirection) -> Self { + appending(DefaultTailwindClass("flex-\(value.token)")) + } + /// `self-`, e.g. `.selfAlign(.start)` → `self-start`. + public func selfAlign(_ align: Align) -> Self { + appending(DefaultTailwindClass("self-\(align.token)")) + } + /// `justify-items-`, e.g. `.justifyItems(.end)`. + public func justifyItems(_ align: Align) -> Self { + appending(DefaultTailwindClass("justify-items-\(align.token)")) + } + /// `justify-self-`, e.g. `.justifySelf(.center)`. + public func justifySelf(_ align: Align) -> Self { + appending(DefaultTailwindClass("justify-self-\(align.token)")) + } + /// `content-`, e.g. `.content(.between)` → `content-between`. + public func content(_ value: Justify) -> Self { + appending(DefaultTailwindClass("content-\(value.token)")) + } + /// `place-items-`, e.g. `.placeItems(.stretch)`. + public func placeItems(_ align: Align) -> Self { + appending(DefaultTailwindClass("place-items-\(align.token)")) + } + /// `place-self-`, e.g. `.placeSelf(.center)`. + public func placeSelf(_ align: Align) -> Self { + appending(DefaultTailwindClass("place-self-\(align.token)")) + } + /// `space-x-`, e.g. `.spaceX(1)`. + public func spaceX(_ amount: DefaultSpacing) -> Self { + appending(DefaultTailwindClass("space-x-\(amount.token)")) + } + /// `space-y-`. + public func spaceY(_ amount: DefaultSpacing) -> Self { + appending(DefaultTailwindClass("space-y-\(amount.token)")) + } +} + +extension TailwindStyleBuilder: FlexGridStyling {} diff --git a/Sources/TailwindKit/Styling/ListStyling.swift b/Sources/TailwindKit/Styling/ListStyling.swift new file mode 100644 index 0000000..ea328e4 --- /dev/null +++ b/Sources/TailwindKit/Styling/ListStyling.swift @@ -0,0 +1,55 @@ +// +// ListStyling.swift +// TailwindKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The list-style utilities. +/// +/// A capability protocol whose members are witnessed against the +/// ``TailwindStyle`` seam; ``TailwindStyleBuilder`` conforms to it. See +/// ``TailwindStyle`` for why the surface is organized this way. +public protocol ListStyling { + /// `list-