Skip to content

Add data themes: shareable YAML progress themes#241

Merged
ahogappa merged 1 commit into
masterfrom
feat/data-themes
Jun 13, 2026
Merged

Add data themes: shareable YAML progress themes#241
ahogappa merged 1 commit into
masterfrom
feat/data-themes

Conversation

@ahogappa

Copy link
Copy Markdown
Owner

Summary

Stacked on #235. Adds data themes: progress-display customization from an inert YAML file — scalars, arrays and hashes only (YAML.safe_load_file), so no code runs and themes can be shared and used without reading them. This is the trust story Liquid was supposed to provide but couldn't (taski themes shipped as Ruby classes, putting the envelope outside the sandbox); data themes move the boundary into the file format itself.

# my-theme.yml
schema: 1
extends: detail
colors:
  green: "\e[38;2;166;227;161m"   # truecolor works
templates:
  task_success: "%{icon} %{name}%{duration_part}"
Taski.progress.theme = "my-theme.yml"   # validated at assignment — Theme::LoadError, never mid-run

Design

  • One vocabulary. Placeholder names ARE the Theme::Base helper names (%{duration_part}, %{icon}, %{spinner}, %{task_names_part}, ...) and are computed by the same helper methods. Docs, tests and mental model are shared between Ruby themes and data themes.
  • Real inheritance. Theme.load builds an anonymous class that actually inherits from the extends theme (base/default/plain/detail/compact — a closed enum, no constant lookup from file input) with the data layer on top. Declared templates render from format strings; everything undeclared falls through (super) to the extends theme on the same instance — so declared colors/icons/spinner/formats/fragments apply to fallback-rendered lines too, exactly like a Ruby subclass. A colors-only theme restyles every line.
  • Logic-free by design. The only-when-present guards stay engine-fixed; arbitrary control flow is the upgrade path to a Ruby theme.
  • Config contract untouched. The loaded class IS-A Theme::Base with zero-arg .new; Config#theme= additionally accepts String/Pathname.

Fail-fast validation — a theme that loads cannot fail at render time

  • Closed key sets at every level (errors name the offending key AND the allowed set)
  • schema: 1 versioning, checked before anything else (a future-schema file says "unsupported schema version", not "unknown key")
  • Type checks; intervals must be finite and within 0.01–60s (.inf would kill the spinner/render threads with RangeError from sleep; tiny values busy-loop the CPU); truncation integers must be positive
  • Stray-% rejection: strip /%%|%\{[^}]*\}/, reject any remaining %%s/%d would otherwise silently inline the whole payload hash via Kernel#format
  • A validation render of every declared template/fragment against a fully-populated sample payload — exhaustive because the render payload is uniform
  • All Psych-level failures (syntax, aliases, disallowed classes like unquoted dates) wrap as Theme::LoadError naming the file
  • User data (task names, stdout, error messages) enters format only as argument values, never as format strings — no injection surface (adversarially verified)

Parity proof

The four shipped themes re-encoded as YAML (test/fixtures/themes/*.yml) reproduce all 132 cases of the Liquid-era byte-parity baseline through the data-theme pipeline.

Also ships

  • examples/themes/catppuccin-mocha.yml (truecolor)
  • First user-facing theme documentation: GUIDE "Customizing Themes" (Ruby-theme contract + helper API + full placeholder table + the precise trust boundary: a theme cannot execute code, but color values are raw escape strings) and a README teaser
  • 51 new tests; suite 822 runs / 0 failures, rake standard clean

Adversarial review

A 39-agent adversarial review (validation bypass / parity-fallback / security / mutation testing / integration) ran before this PR. Security probes confirmed no code-execution path and no format-string injection. 24 confirmed findings (heavily overlapping across angles) were folded in:

  • Fallback semantics restructure (the big one): the original delegation design meant a colors-only theme was a silent no-op and declared spinner frames could desync from fallback-rendered lines. Fixed by making loaded themes real subclasses of the extends theme (verified: the catppuccin example now styles clean_*/group_* fallback lines).
  • rescue Psych::Exception (aliases/date scalars no longer escape as raw Psych errors through the documented LoadError contract)
  • Interval bounds (.inf/1e20/1e-4 rejected at load — previously killed the render thread and aborted on_stop teardown, leaving the cursor hidden)
  • Symbol-keyed from_hash input now normalized (previously {schema: 99} bypassed the schema gate silently)
  • schema must be exactly 1; checked before unknown-key validation
  • Positive-integer validation for truncation.list_limit/text_max; deep-frozen definition strings; README example path fixed; format_duration 1000ms-boundary mutant killed

🤖 Generated with Claude Code


(Recreated from #236, which GitHub auto-closed when its base branch feat/ruby-native-themes was deleted on merge of #235. Rebased onto master; gates green 822 runs / 0 failures.)

@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@ahogappa, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 39 minutes and 4 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba7c2290-42db-48a6-b167-1d3ab7756114

📥 Commits

Reviewing files that changed from the base of the PR and between eb7099b and 1161195.

📒 Files selected for processing (10)
  • README.md
  • docs/GUIDE.md
  • examples/themes/catppuccin-mocha.yml
  • lib/taski/progress/config.rb
  • lib/taski/progress/theme/declarative.rb
  • test/fixtures/themes/compact.yml
  • test/fixtures/themes/default.yml
  • test/fixtures/themes/detail.yml
  • test/fixtures/themes/plain.yml
  • test/test_theme_declarative.rb
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/data-themes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

A data theme is an inert YAML file — scalars, arrays and hashes only,
loaded with YAML.safe_load_file — so themes can be shared and used
without reading them. This is the trust story Liquid was supposed to
provide but could not: taski themes shipped as Ruby classes, putting
the envelope outside the sandbox. Data themes move the boundary to the
file format itself: no code runs.

Templates are %{placeholder} format strings whose names ARE the
Theme::Base helper vocabulary (%{duration_part}, %{icon}, %{spinner},
%{task_names_part}, ...) — one vocabulary across Ruby themes and data
themes, computed by the same helper methods. Icons, colors (truecolor
works: YAML double-quoted "\e[38;2;...m" parses to ESC), spinner
frames/intervals, count/duration formats, truncation knobs, and the
*_part fragment wrapper strings are all data; the only-when-present
guards stay engine-fixed. Anything undeclared falls back to the
`extends` theme (base/default/plain/detail/compact — a closed enum, no
constant lookup from file input).

Theme.load(path) / Theme.from_hash(hash) validate everything fail-fast
and bake a frozen Definition into an anonymous Theme::Declarative
subclass — IS-A Theme::Base with zero-arg .new, so Config's existing
theme contract is untouched. Config#theme= additionally accepts a
String/Pathname (raising Theme::LoadError at assignment, never
mid-run). Validation closes every hole found in design review: closed
key sets at every level (unknown keys name the allowed set), schema
versioning (schema: 1), type checks, a stray-% check (strip
/%%|%\{[^}]*\}/ then reject any remaining % — %s/%d would otherwise
silently inline the whole payload hash via Kernel#format), and a
validation render of every declared template/fragment against a fully
populated sample payload, which is exhaustive because the render
payload is uniform. A theme that loads cannot fail at render time; if
anything slips through anyway it hits the existing render_theme
isolation.

Parity proof: the four shipped themes re-encoded as YAML
(test/fixtures/themes/*.yml) reproduce all 132 cases of the Liquid-era
byte-parity baseline through the data-theme pipeline.

Also ships examples/themes/catppuccin-mocha.yml (truecolor) and the
first user-facing theme documentation (GUIDE "Customizing Themes":
Ruby-theme contract + helper API + full placeholder table; README
teaser).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ahogappa
ahogappa merged commit 4e0ea6b into master Jun 13, 2026
8 checks passed
@ahogappa
ahogappa deleted the feat/data-themes branch June 13, 2026 22:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant