hr_lite 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +33 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +166 -0
  5. data/Rakefile +3 -0
  6. data/app/assets/javascripts/hr_lite/geo_punch.js +61 -0
  7. data/app/assets/javascripts/hr_lite/mention.js +100 -0
  8. data/app/assets/stylesheets/hr_lite/application.css +15 -0
  9. data/app/assets/stylesheets/hr_lite/hr_lite.css +239 -0
  10. data/app/controllers/hr_lite/admin/appraisals_controller.rb +74 -0
  11. data/app/controllers/hr_lite/admin/attendances_controller.rb +79 -0
  12. data/app/controllers/hr_lite/admin/audit_logs_controller.rb +9 -0
  13. data/app/controllers/hr_lite/admin/base_controller.rb +16 -0
  14. data/app/controllers/hr_lite/admin/designation_changes_controller.rb +33 -0
  15. data/app/controllers/hr_lite/admin/employees_controller.rb +53 -0
  16. data/app/controllers/hr_lite/admin/holidays_controller.rb +70 -0
  17. data/app/controllers/hr_lite/admin/leadership_controller.rb +18 -0
  18. data/app/controllers/hr_lite/admin/leave_balances_controller.rb +41 -0
  19. data/app/controllers/hr_lite/admin/leave_requests_controller.rb +41 -0
  20. data/app/controllers/hr_lite/admin/leave_types_controller.rb +54 -0
  21. data/app/controllers/hr_lite/admin/office_locations_controller.rb +46 -0
  22. data/app/controllers/hr_lite/admin/overview_controller.rb +12 -0
  23. data/app/controllers/hr_lite/admin/payroll_runs_controller.rb +93 -0
  24. data/app/controllers/hr_lite/admin/salary_slips_controller.rb +44 -0
  25. data/app/controllers/hr_lite/admin/salary_structures_controller.rb +49 -0
  26. data/app/controllers/hr_lite/admin/settings_controller.rb +18 -0
  27. data/app/controllers/hr_lite/application_controller.rb +76 -0
  28. data/app/controllers/hr_lite/appraisals_controller.rb +18 -0
  29. data/app/controllers/hr_lite/attendance_controller.rb +37 -0
  30. data/app/controllers/hr_lite/calendar_controller.rb +20 -0
  31. data/app/controllers/hr_lite/career_controller.rb +9 -0
  32. data/app/controllers/hr_lite/employee_profiles_controller.rb +9 -0
  33. data/app/controllers/hr_lite/holidays_controller.rb +9 -0
  34. data/app/controllers/hr_lite/home_controller.rb +7 -0
  35. data/app/controllers/hr_lite/kudos_controller.rb +36 -0
  36. data/app/controllers/hr_lite/leave_balances_controller.rb +11 -0
  37. data/app/controllers/hr_lite/leave_requests_controller.rb +56 -0
  38. data/app/controllers/hr_lite/salary_slips_controller.rb +39 -0
  39. data/app/controllers/hr_lite/users_controller.rb +9 -0
  40. data/app/helpers/hr_lite/application_helper.rb +105 -0
  41. data/app/jobs/hr_lite/application_job.rb +4 -0
  42. data/app/jobs/hr_lite/daily_digest_job.rb +33 -0
  43. data/app/jobs/hr_lite/leave_year_rollover_job.rb +29 -0
  44. data/app/mailers/hr_lite/application_mailer.rb +5 -0
  45. data/app/mailers/hr_lite/event_mailer.rb +33 -0
  46. data/app/models/concerns/hr_lite/audited.rb +70 -0
  47. data/app/models/concerns/hr_lite/encrypted_money.rb +32 -0
  48. data/app/models/hr_lite/application_record.rb +5 -0
  49. data/app/models/hr_lite/appraisal.rb +89 -0
  50. data/app/models/hr_lite/attendance_record.rb +41 -0
  51. data/app/models/hr_lite/audit_log.rb +17 -0
  52. data/app/models/hr_lite/designation_change.rb +52 -0
  53. data/app/models/hr_lite/employee_profile.rb +72 -0
  54. data/app/models/hr_lite/holiday.rb +18 -0
  55. data/app/models/hr_lite/kudo.rb +54 -0
  56. data/app/models/hr_lite/kudo_mention.rb +8 -0
  57. data/app/models/hr_lite/leave_balance.rb +42 -0
  58. data/app/models/hr_lite/leave_request.rb +207 -0
  59. data/app/models/hr_lite/leave_type.rb +22 -0
  60. data/app/models/hr_lite/office_location.rb +21 -0
  61. data/app/models/hr_lite/payroll_run.rb +112 -0
  62. data/app/models/hr_lite/salary_slip.rb +78 -0
  63. data/app/models/hr_lite/salary_structure.rb +49 -0
  64. data/app/models/hr_lite/setting.rb +14 -0
  65. data/app/services/hr_lite/attendance_puncher.rb +103 -0
  66. data/app/services/hr_lite/attendance_summary.rb +98 -0
  67. data/app/services/hr_lite/calculators/esi.rb +25 -0
  68. data/app/services/hr_lite/calculators/pf.rb +32 -0
  69. data/app/services/hr_lite/calculators/professional_tax.rb +20 -0
  70. data/app/services/hr_lite/calculators/proration.rb +30 -0
  71. data/app/services/hr_lite/calculators/tds.rb +74 -0
  72. data/app/services/hr_lite/day_status.rb +49 -0
  73. data/app/services/hr_lite/leave_day_counter.rb +24 -0
  74. data/app/services/hr_lite/overview_query.rb +39 -0
  75. data/app/services/hr_lite/payroll_run_processor.rb +46 -0
  76. data/app/services/hr_lite/pdf_renderer.rb +23 -0
  77. data/app/services/hr_lite/slip_builder.rb +106 -0
  78. data/app/services/hr_lite/working_calendar.rb +41 -0
  79. data/app/views/hr_lite/admin/appraisals/_form.html.erb +29 -0
  80. data/app/views/hr_lite/admin/appraisals/edit.html.erb +6 -0
  81. data/app/views/hr_lite/admin/appraisals/new.html.erb +6 -0
  82. data/app/views/hr_lite/admin/attendances/index.html.erb +49 -0
  83. data/app/views/hr_lite/admin/attendances/show.html.erb +55 -0
  84. data/app/views/hr_lite/admin/audit_logs/index.html.erb +39 -0
  85. data/app/views/hr_lite/admin/designation_changes/new.html.erb +19 -0
  86. data/app/views/hr_lite/admin/employees/_form.html.erb +40 -0
  87. data/app/views/hr_lite/admin/employees/edit.html.erb +3 -0
  88. data/app/views/hr_lite/admin/employees/index.html.erb +40 -0
  89. data/app/views/hr_lite/admin/employees/new.html.erb +3 -0
  90. data/app/views/hr_lite/admin/employees/show.html.erb +75 -0
  91. data/app/views/hr_lite/admin/holidays/index.html.erb +59 -0
  92. data/app/views/hr_lite/admin/leave_balances/index.html.erb +58 -0
  93. data/app/views/hr_lite/admin/leave_requests/index.html.erb +35 -0
  94. data/app/views/hr_lite/admin/leave_requests/show.html.erb +39 -0
  95. data/app/views/hr_lite/admin/leave_types/_form.html.erb +27 -0
  96. data/app/views/hr_lite/admin/leave_types/edit.html.erb +10 -0
  97. data/app/views/hr_lite/admin/leave_types/index.html.erb +39 -0
  98. data/app/views/hr_lite/admin/leave_types/new.html.erb +3 -0
  99. data/app/views/hr_lite/admin/office_locations/_form.html.erb +18 -0
  100. data/app/views/hr_lite/admin/office_locations/edit.html.erb +10 -0
  101. data/app/views/hr_lite/admin/office_locations/index.html.erb +30 -0
  102. data/app/views/hr_lite/admin/office_locations/new.html.erb +3 -0
  103. data/app/views/hr_lite/admin/overview/index.html.erb +72 -0
  104. data/app/views/hr_lite/admin/payroll_runs/index.html.erb +33 -0
  105. data/app/views/hr_lite/admin/payroll_runs/new.html.erb +20 -0
  106. data/app/views/hr_lite/admin/payroll_runs/show.html.erb +76 -0
  107. data/app/views/hr_lite/admin/salary_slips/show.html.erb +42 -0
  108. data/app/views/hr_lite/admin/salary_structures/_form.html.erb +25 -0
  109. data/app/views/hr_lite/admin/salary_structures/edit.html.erb +6 -0
  110. data/app/views/hr_lite/admin/salary_structures/new.html.erb +6 -0
  111. data/app/views/hr_lite/admin/settings/edit.html.erb +18 -0
  112. data/app/views/hr_lite/appraisals/index.html.erb +22 -0
  113. data/app/views/hr_lite/appraisals/show.html.erb +32 -0
  114. data/app/views/hr_lite/attendance/_month_grid.html.erb +41 -0
  115. data/app/views/hr_lite/attendance/_punch_card.html.erb +41 -0
  116. data/app/views/hr_lite/attendance/show.html.erb +22 -0
  117. data/app/views/hr_lite/calendar/show.html.erb +45 -0
  118. data/app/views/hr_lite/career/show.html.erb +49 -0
  119. data/app/views/hr_lite/employee_profiles/show.html.erb +22 -0
  120. data/app/views/hr_lite/event_mailer/event.html.erb +10 -0
  121. data/app/views/hr_lite/event_mailer/event.text.erb +10 -0
  122. data/app/views/hr_lite/event_mailer/leadership.html.erb +27 -0
  123. data/app/views/hr_lite/event_mailer/leadership.text.erb +16 -0
  124. data/app/views/hr_lite/holidays/_next_holiday.html.erb +12 -0
  125. data/app/views/hr_lite/holidays/index.html.erb +30 -0
  126. data/app/views/hr_lite/home/index.html.erb +25 -0
  127. data/app/views/hr_lite/kudos/_kudo.html.erb +17 -0
  128. data/app/views/hr_lite/kudos/index.html.erb +39 -0
  129. data/app/views/hr_lite/leave_balances/index.html.erb +19 -0
  130. data/app/views/hr_lite/leave_requests/_balance_chips.html.erb +17 -0
  131. data/app/views/hr_lite/leave_requests/index.html.erb +37 -0
  132. data/app/views/hr_lite/leave_requests/new.html.erb +36 -0
  133. data/app/views/hr_lite/leave_requests/show.html.erb +25 -0
  134. data/app/views/hr_lite/salary_slips/_slip_detail.html.erb +48 -0
  135. data/app/views/hr_lite/salary_slips/index.html.erb +29 -0
  136. data/app/views/hr_lite/salary_slips/pdf.html.erb +72 -0
  137. data/app/views/hr_lite/salary_slips/show.html.erb +12 -0
  138. data/app/views/hr_lite/shared/_form_errors.html.erb +10 -0
  139. data/app/views/hr_lite/shared/_pagination.html.erb +11 -0
  140. data/app/views/layouts/hr_lite/application.html.erb +56 -0
  141. data/app/views/layouts/hr_lite/mailer.html.erb +30 -0
  142. data/app/views/layouts/hr_lite/mailer.text.erb +6 -0
  143. data/app/views/layouts/hr_lite/pdf.html.erb +26 -0
  144. data/config/routes.rb +57 -0
  145. data/db/migrate/20260718190726_create_hr_lite_kudos.rb +14 -0
  146. data/db/migrate/20260718190727_create_hr_lite_kudo_mentions.rb +12 -0
  147. data/db/migrate/20260718190728_create_hr_lite_audit_logs.rb +15 -0
  148. data/db/migrate/20260718192342_create_hr_lite_office_locations.rb +13 -0
  149. data/db/migrate/20260718192343_create_hr_lite_attendance_records.rb +27 -0
  150. data/db/migrate/20260718193045_create_hr_lite_holidays.rb +14 -0
  151. data/db/migrate/20260718193046_create_hr_lite_leave_types.rb +20 -0
  152. data/db/migrate/20260718193047_create_hr_lite_leave_balances.rb +17 -0
  153. data/db/migrate/20260718193048_create_hr_lite_leave_requests.rb +23 -0
  154. data/db/migrate/20260718193049_create_hr_lite_settings.rb +10 -0
  155. data/db/migrate/20260718194534_create_hr_lite_employee_profiles.rb +27 -0
  156. data/db/migrate/20260718194535_create_hr_lite_salary_structures.rb +23 -0
  157. data/db/migrate/20260718194536_create_hr_lite_payroll_runs.rb +20 -0
  158. data/db/migrate/20260718194537_create_hr_lite_salary_slips.rb +28 -0
  159. data/db/migrate/20260718195953_create_hr_lite_appraisals.rb +22 -0
  160. data/db/migrate/20260718195954_create_hr_lite_designation_changes.rb +17 -0
  161. data/lib/generators/hr_lite/install/install_generator.rb +29 -0
  162. data/lib/generators/hr_lite/install/templates/AFTER_INSTALL +35 -0
  163. data/lib/generators/hr_lite/install/templates/initializer.rb +45 -0
  164. data/lib/hr_lite/configuration.rb +60 -0
  165. data/lib/hr_lite/current.rb +7 -0
  166. data/lib/hr_lite/engine.rb +39 -0
  167. data/lib/hr_lite/geo.rb +22 -0
  168. data/lib/hr_lite/mention_parser.rb +18 -0
  169. data/lib/hr_lite/money.rb +33 -0
  170. data/lib/hr_lite/notifications.rb +99 -0
  171. data/lib/hr_lite/seeds.rb +42 -0
  172. data/lib/hr_lite/statutory_rate_card.rb +60 -0
  173. data/lib/hr_lite/version.rb +3 -0
  174. data/lib/hr_lite.rb +104 -0
  175. data/lib/tasks/hr_lite_tasks.rake +8 -0
  176. metadata +250 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9dc0713440cde019ffbf731932a0efef58980b58baff2ccb55991aecae807714
4
+ data.tar.gz: 3cbfdca3513b1d5ce604accb369f7c63e425d98ce5efe88ad2544b070900f284
5
+ SHA512:
6
+ metadata.gz: 3e6a06f68ce96bb223860cd8ddb091986207958acccdd2ee4947c066a7885e119ae7d79e954e5ca9514eabb072b7ff3da46a95cc91463a702b553979de74e1da
7
+ data.tar.gz: 72e3acb4196071320611198ac549a9c5f6301182ddd882240cb12ccfb97f0257e5d21915812b48afe760b38c41addb7be15d159400bcb8d13c8bb04c2aa5d2d5
data/CHANGELOG.md ADDED
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-07-19
11
+
12
+ Initial release.
13
+
14
+ ### Added
15
+
16
+ - Attendance: geolocated check-in/out with office-radius flagging (never
17
+ blocking), month grids, admin team day view and audited regularization.
18
+ - Leave: policy-driven types, hybrid live-computed balances, half-days,
19
+ race-safe approvals, cancellations, holiday calendar with bulk paste,
20
+ weekend policy (sun-only / sat-sun / 2nd-4th Saturday), company calendar.
21
+ - Payroll: versioned salary structures, date-keyed statutory rate card,
22
+ PF/ESI/PT/TDS calculators, LOP-prorated runs with review overrides,
23
+ publishable salary slips with PDFs and a payout register CSV. Money and
24
+ identity PII encrypted at rest.
25
+ - Kudos wall with @mentions and badges.
26
+ - Appraisals (draft -> shared, permanent once shared) and promotions with a
27
+ designation timeline and host sync hook.
28
+ - Three-tier access (employee / admin / configurable leadership), an event
29
+ bus with per-event channel matrix (bell, email, leadership email/bell),
30
+ daily leadership digest and an append-only audit trail.
31
+
32
+ [Unreleased]: https://github.com/kshtzkr/hr_lite/compare/v0.1.0...HEAD
33
+ [0.1.0]: https://github.com/kshtzkr/hr_lite/releases/tag/v0.1.0
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright kshitiz sinha
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,166 @@
1
+ # hr_lite
2
+
3
+ [![CI](https://github.com/kshtzkr/hr_lite/actions/workflows/ci.yml/badge.svg)](https://github.com/kshtzkr/hr_lite/actions/workflows/ci.yml)
4
+ [![Gem Version](https://badge.fury.io/rb/hr_lite.svg)](https://rubygems.org/gems/hr_lite)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
+
7
+ A lightweight, self-contained HRMS engine for Rails — attendance with
8
+ geolocation, leave management, holiday calendar, full Indian payroll
9
+ (PF/ESI/PT/TDS) with salary-slip PDFs, kudos with @mentions, appraisals and
10
+ promotions. Think Keka-lite, mounted inside your existing app in a few lines.
11
+
12
+ - **Mountable engine**, isolated namespace (`HrLite`), tables prefixed `hr_lite_`.
13
+ - **Bring your own users**: any user model + auth (Devise or otherwise) via config hooks.
14
+ - **Three access tiers**: employee (self-service), admin (day-to-day operations),
15
+ leadership (policy, money, payroll — a configurable email list).
16
+ - **Powerful notifications**: one event bus routing every HR event to in-app
17
+ bells (your notifier), employee emails, and leadership emails — with an
18
+ append-only audit trail of every governing change.
19
+ - **Self-contained UI**: mobile-first, dependency-free JS, all theming via
20
+ CSS custom properties (`--hrl-*`).
21
+ - **Encrypted at rest**: PAN/UAN/bank numbers and every money amount use
22
+ ActiveRecord encryption.
23
+
24
+ ## Installation
25
+
26
+ Everything lives in the gem — a host app adds the gem, a mount line and one
27
+ initializer. Nothing else.
28
+
29
+ ```ruby
30
+ # Gemfile
31
+ gem "hr_lite"
32
+ ```
33
+
34
+ ```bash
35
+ bin/rails g hr_lite:install # annotated initializer + next steps
36
+ bin/rails g hr_lite:install --route # …and mount at /hr for you
37
+ bin/rails db:migrate # engine migrations load straight from the gem
38
+ bin/rails hr_lite:seed # leave types + fixed national holidays (idempotent)
39
+ ```
40
+
41
+ Migrations are served from the gem (upgrades included) — nothing is copied
42
+ into your repo. If you prefer copies, run `bin/rails hr_lite:install:migrations`
43
+ once and the engine steps aside.
44
+
45
+ To serve it on a subdomain instead of a path:
46
+
47
+ ```ruby
48
+ # config/routes.rb
49
+ constraints subdomain: "hr" do
50
+ mount HrLite::Engine => "/", as: :hr_lite
51
+ end
52
+ ```
53
+
54
+ Your app must have ActiveRecord encryption keys configured
55
+ (`bin/rails db:encryption:init` if you have not).
56
+
57
+ ## Configuration
58
+
59
+ ```ruby
60
+ # config/initializers/hr_lite.rb
61
+ HrLite.configure do |c|
62
+ c.parent_controller = "ApplicationController" # inherits your auth, set BEFORE boot
63
+ c.user_class = "User"
64
+ c.current_user_method = :current_user
65
+ c.authenticate_method = :authenticate_user!
66
+ c.admin_check = ->(user) { user.admin? }
67
+
68
+ # Leadership: the governing tier. Only these people can change leave
69
+ # policy, offices, holidays, employee profiles, salary structures,
70
+ # payroll, appraisals. Change the list, not the code.
71
+ c.leadership_emails = ENV.fetch("HR_LEADERSHIP_EMAILS", "").split(",").map(&:strip)
72
+
73
+ # In-app notifications -> your bell system.
74
+ c.notify = ->(user:, kind:, title:, body:, path:) {
75
+ StaffNotifier.notify(user, kind: kind, title: title, body: body, path: path)
76
+ }
77
+
78
+ c.mailer_from = "hr@example.com"
79
+ c.mail_link_base = "https://hr.example.com" # enables deep links in emails
80
+ c.company = -> { { name: "Acme", address: "Head office address", logo_path: nil } }
81
+
82
+ # Salary-slip PDFs: plug your renderer, or add wicked_pdf to your bundle
83
+ # and the built-in renderer takes over. Without either, PDF is disabled.
84
+ c.render_pdf = ->(template:, assigns:, cache_key:) {
85
+ PdfRenderer.render(template: template, assigns: assigns, cache_key: cache_key)
86
+ }
87
+
88
+ # Mirror promotions into your own user model (optional).
89
+ c.on_designation_change = ->(user, designation) { user.update!(designation: designation) }
90
+ end
91
+ ```
92
+
93
+ Every event's channels are configurable via `c.notification_matrix` — see
94
+ `HrLite::Notifications::DEFAULT_MATRIX` for the routing table (bell /
95
+ employee email / leadership email / leadership bell per event).
96
+
97
+ ## Recurring jobs (host scheduler)
98
+
99
+ | Job | Schedule | Purpose |
100
+ |---|---|---|
101
+ | `HrLite::DailyDigestJob` | mornings | leadership digest: out today, pending approvals, flagged punches |
102
+ | `HrLite::LeaveYearRolloverJob` | Jan 1 | carry-forward balances (idempotent) |
103
+
104
+ ## Theming
105
+
106
+ Override the CSS variables, nothing else:
107
+
108
+ ```css
109
+ :root { --hrl-accent: #00a24f; --hrl-font: "Inter", sans-serif; }
110
+ ```
111
+
112
+ All views are also overridable via standard engine view precedence.
113
+
114
+ ## Docs
115
+
116
+ - [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config key, the event
117
+ list and the notification matrix, how the three access tiers gate.
118
+ - [docs/PAYROLL.md](docs/PAYROLL.md) — the exact payroll math, rounding rules,
119
+ run lifecycle, and what is deliberately not modelled.
120
+
121
+ ## Statutory disclaimer
122
+
123
+ Payroll math (PF, ESI, PT, TDS) is projection-grade and configured in
124
+ `HrLite::StatutoryRateCard` — a date-keyed rate table. **Verify the rates for
125
+ each financial year with your accountant** before the first run; per-slip
126
+ LOP/TDS overrides are the escape hatch. Surcharge, perquisites and
127
+ HRA-exemption math are not modelled.
128
+
129
+ ## Requirements & versioning
130
+
131
+ - Rails >= 8.0, Ruby >= 3.2, any ActiveRecord database (portable column
132
+ types; developed against PostgreSQL, CI runs SQLite).
133
+ - The host app must have ActiveRecord encryption keys configured.
134
+ - [SemVer](https://semver.org): 0.x minors may break with a CHANGELOG note;
135
+ from 1.0, breaking changes only in majors.
136
+
137
+ ## Documentation
138
+
139
+ - [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — every config hook, the
140
+ event/notification matrix, access-tier gating.
141
+ - [docs/PAYROLL.md](docs/PAYROLL.md) — exact payroll math, rounding rules,
142
+ and what is deliberately not modelled.
143
+ - [CHANGELOG.md](CHANGELOG.md) — release history.
144
+
145
+ ## Contributing
146
+
147
+ Bug reports and pull requests are welcome — see
148
+ [CONTRIBUTING.md](CONTRIBUTING.md) for setup and ground rules (100%
149
+ coverage, BigDecimal money, config hooks over host couplings). Security
150
+ issues: report privately per [SECURITY.md](SECURITY.md). Everyone
151
+ interacting with this project is expected to follow the
152
+ [code of conduct](CODE_OF_CONDUCT.md).
153
+
154
+ ## Development
155
+
156
+ ```bash
157
+ bundle install
158
+ bundle exec rspec # dummy-app test suite (100% line coverage)
159
+ COVERAGE=1 bundle exec rspec
160
+ bundle exec rubocop
161
+ ```
162
+
163
+ ## License
164
+
165
+ The gem is available as open source under the terms of the
166
+ [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,61 @@
1
+ // Dependency-free geolocation capture for punch forms.
2
+ // Markup contract:
3
+ // <form data-hrl-geo-punch>
4
+ // <input type="hidden" name="lat"><input type="hidden" name="lng">
5
+ // <input type="hidden" name="accuracy_m"><input type="hidden" name="geo_status">
6
+ // <button type="submit">…</button>
7
+ // <span data-hrl-geo-status hidden></span>
8
+ // </form>
9
+ // The punch NEVER blocks on GPS: denied/timeout/unavailable submit anyway
10
+ // with geo_status set; the server flags them.
11
+ (function () {
12
+ "use strict";
13
+
14
+ function setup(form) {
15
+ var fields = {
16
+ lat: form.querySelector("[name=lat]"),
17
+ lng: form.querySelector("[name=lng]"),
18
+ accuracy: form.querySelector("[name=accuracy_m]"),
19
+ status: form.querySelector("[name=geo_status]")
20
+ };
21
+ var button = form.querySelector("[type=submit]");
22
+ var label = form.querySelector("[data-hrl-geo-status]");
23
+ var locating = false;
24
+
25
+ function send(status) {
26
+ fields.status.value = status;
27
+ locating = false;
28
+ form.submit();
29
+ }
30
+
31
+ form.addEventListener("submit", function (event) {
32
+ if (locating || fields.status.value) return; // second pass: let it through
33
+ event.preventDefault();
34
+ locating = true;
35
+ if (button) { button.disabled = true; button.setAttribute("aria-busy", "true"); }
36
+ if (label) { label.hidden = false; label.textContent = "Getting location…"; }
37
+
38
+ if (!navigator.geolocation) { send("unavailable"); return; }
39
+
40
+ navigator.geolocation.getCurrentPosition(
41
+ function (pos) {
42
+ fields.lat.value = pos.coords.latitude.toFixed(6);
43
+ fields.lng.value = pos.coords.longitude.toFixed(6);
44
+ fields.accuracy.value = Math.round(pos.coords.accuracy);
45
+ send("ok");
46
+ },
47
+ function (err) {
48
+ send(err && err.code === 1 ? "denied" : "timeout");
49
+ },
50
+ { enableHighAccuracy: true, timeout: 10000, maximumAge: 60000 }
51
+ );
52
+ });
53
+ }
54
+
55
+ function init() {
56
+ document.querySelectorAll("form[data-hrl-geo-punch]").forEach(setup);
57
+ }
58
+
59
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
60
+ else init();
61
+ })();
@@ -0,0 +1,100 @@
1
+ // Dependency-free @mention autocomplete for textareas.
2
+ // Markup contract:
3
+ // <div class="hrl-mention-wrap" data-hrl-mention data-search-url="...">
4
+ // <textarea ...></textarea>
5
+ // </div>
6
+ // Picking a user inserts the visible marker "@[Name](id) " which the server
7
+ // parses via HrLite::MentionParser.
8
+ (function () {
9
+ "use strict";
10
+
11
+ var TRIGGER = /@([\w.\-]{1,30})$/;
12
+
13
+ function debounce(fn, ms) {
14
+ var t;
15
+ return function () {
16
+ var args = arguments, self = this;
17
+ clearTimeout(t);
18
+ t = setTimeout(function () { fn.apply(self, args); }, ms);
19
+ };
20
+ }
21
+
22
+ function setup(wrap) {
23
+ var textarea = wrap.querySelector("textarea");
24
+ var url = wrap.getAttribute("data-search-url");
25
+ if (!textarea || !url) return;
26
+
27
+ var menu = document.createElement("div");
28
+ menu.className = "hrl-mention-menu";
29
+ menu.hidden = true;
30
+ wrap.appendChild(menu);
31
+ var active = -1;
32
+
33
+ function close() { menu.hidden = true; menu.innerHTML = ""; active = -1; }
34
+
35
+ function pick(item) {
36
+ var caret = textarea.selectionStart;
37
+ var before = textarea.value.slice(0, caret);
38
+ var after = textarea.value.slice(caret);
39
+ var replaced = before.replace(TRIGGER, "@[" + item.text + "](" + item.value + ") ");
40
+ textarea.value = replaced + after;
41
+ var pos = replaced.length;
42
+ textarea.focus();
43
+ textarea.setSelectionRange(pos, pos);
44
+ close();
45
+ }
46
+
47
+ function renderResults(items) {
48
+ close();
49
+ if (!items.length) return;
50
+ items.slice(0, 8).forEach(function (item) {
51
+ var btn = document.createElement("button");
52
+ btn.type = "button";
53
+ btn.textContent = item.text;
54
+ btn.addEventListener("mousedown", function (e) { e.preventDefault(); pick(item); });
55
+ menu.appendChild(btn);
56
+ });
57
+ menu.hidden = false;
58
+ }
59
+
60
+ var lookup = debounce(function (query) {
61
+ fetch(url + "?q=" + encodeURIComponent(query), { headers: { Accept: "application/json" } })
62
+ .then(function (r) { return r.ok ? r.json() : []; })
63
+ .then(renderResults)
64
+ .catch(close);
65
+ }, 200);
66
+
67
+ textarea.addEventListener("input", function () {
68
+ var before = textarea.value.slice(0, textarea.selectionStart);
69
+ var match = TRIGGER.exec(before);
70
+ if (match && match[1].length >= 2) lookup(match[1]);
71
+ else close();
72
+ });
73
+
74
+ textarea.addEventListener("keydown", function (e) {
75
+ if (menu.hidden) return;
76
+ var buttons = menu.querySelectorAll("button");
77
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
78
+ e.preventDefault();
79
+ active = e.key === "ArrowDown" ? Math.min(active + 1, buttons.length - 1) : Math.max(active - 1, 0);
80
+ buttons.forEach(function (b, i) { b.classList.toggle("hrl-active", i === active); });
81
+ } else if (e.key === "Enter" && active >= 0) {
82
+ e.preventDefault();
83
+ buttons[active].dispatchEvent(new MouseEvent("mousedown"));
84
+ } else if (e.key === "Escape") {
85
+ close();
86
+ }
87
+ });
88
+
89
+ document.addEventListener("click", function (e) {
90
+ if (!wrap.contains(e.target)) close();
91
+ });
92
+ }
93
+
94
+ function init() {
95
+ document.querySelectorAll("[data-hrl-mention]").forEach(setup);
96
+ }
97
+
98
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
99
+ else init();
100
+ })();
@@ -0,0 +1,15 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
@@ -0,0 +1,239 @@
1
+ /* hr_lite — self-contained, mobile-first (360px) HR shell.
2
+ Every brand decision is a CSS custom property; hosts restyle by
3
+ overriding the variables below (see README), never by editing this file. */
4
+
5
+ :root {
6
+ --hrl-font: -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
7
+ --hrl-accent: #2563eb;
8
+ --hrl-accent-ink: #ffffff;
9
+ --hrl-ink: #111827;
10
+ --hrl-muted: #6b7280;
11
+ --hrl-bg: #f4f5f7;
12
+ --hrl-card: #ffffff;
13
+ --hrl-line: #e5e7eb;
14
+ --hrl-ok: #047857;
15
+ --hrl-ok-bg: #d1fae5;
16
+ --hrl-warn: #b45309;
17
+ --hrl-warn-bg: #fef3c7;
18
+ --hrl-bad: #b91c1c;
19
+ --hrl-bad-bg: #fee2e2;
20
+ --hrl-info-bg: #dbeafe;
21
+ --hrl-radius: 14px;
22
+ --hrl-radius-sm: 9px;
23
+ --hrl-shadow: 0 1px 3px rgba(17, 24, 39, .07);
24
+ }
25
+
26
+ * { box-sizing: border-box; }
27
+ .hrl-body {
28
+ margin: 0;
29
+ font-family: var(--hrl-font);
30
+ color: var(--hrl-ink);
31
+ background: var(--hrl-bg);
32
+ -webkit-font-smoothing: antialiased;
33
+ overflow-x: hidden;
34
+ }
35
+ .hrl-body a { color: var(--hrl-accent); text-decoration: none; }
36
+ .hrl-main {
37
+ padding: .85rem;
38
+ padding-bottom: calc(4.4rem + env(safe-area-inset-bottom));
39
+ max-width: 68rem;
40
+ margin: 0 auto;
41
+ }
42
+ .hrl-main > * { min-width: 0; }
43
+
44
+ /* App bar */
45
+ .hrl-appbar {
46
+ background: var(--hrl-card);
47
+ border-bottom: 1px solid var(--hrl-line);
48
+ padding-top: env(safe-area-inset-top);
49
+ position: sticky; top: 0; z-index: 20;
50
+ }
51
+ .hrl-appbar__inner {
52
+ display: flex; align-items: center; gap: .75rem;
53
+ padding: .6rem .85rem; max-width: 68rem; margin: 0 auto;
54
+ }
55
+ .hrl-appbar__brand { font-weight: 700; font-size: 1.02rem; color: var(--hrl-ink); }
56
+ .hrl-appbar__user { margin-left: auto; color: var(--hrl-muted); font-size: .82rem; white-space: nowrap; }
57
+
58
+ /* Nav: hidden inline on phones (tabbar covers it), row on >=768px */
59
+ .hrl-nav--top { display: none; }
60
+ .hrl-nav__sep { width: 1px; align-self: stretch; background: var(--hrl-line); margin: 0 .2rem; }
61
+ .hrl-nav__link {
62
+ color: var(--hrl-muted); font-size: .88rem; padding: .5rem .6rem;
63
+ border-radius: var(--hrl-radius-sm); min-height: 44px;
64
+ display: inline-flex; align-items: center;
65
+ }
66
+ .hrl-nav__link--active { color: var(--hrl-accent); background: var(--hrl-info-bg); font-weight: 600; }
67
+ .hrl-nav__link--back { margin-left: .25rem; border: 1px solid var(--hrl-line); }
68
+
69
+ /* Bottom tab bar (phone) */
70
+ .hrl-tabbar {
71
+ position: fixed; bottom: 0; left: 0; right: 0; z-index: 20;
72
+ display: flex; justify-content: space-around;
73
+ background: var(--hrl-card); border-top: 1px solid var(--hrl-line);
74
+ padding: .25rem .25rem calc(.25rem + env(safe-area-inset-bottom));
75
+ }
76
+ .hrl-tabbar .hrl-nav__link { flex: 1; justify-content: center; font-size: .78rem; }
77
+
78
+ @media (min-width: 768px) {
79
+ .hrl-nav--top { display: flex; flex-wrap: wrap; gap: .1rem; align-items: center; }
80
+ .hrl-tabbar { display: none; }
81
+ .hrl-main { padding: 1.25rem; padding-bottom: 2rem; }
82
+ }
83
+
84
+ /* Flash */
85
+ .hrl-flash { padding: .7rem .9rem; border-radius: var(--hrl-radius-sm); margin-bottom: .85rem; font-size: .9rem; }
86
+ .hrl-flash--ok { background: var(--hrl-ok-bg); color: var(--hrl-ok); }
87
+ .hrl-flash--warn { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
88
+
89
+ /* Page scaffold */
90
+ .hrl-page__head { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin: .25rem 0 .9rem; }
91
+ .hrl-page__title { font-size: 1.25rem; font-weight: 700; margin: 0; }
92
+ .hrl-page__sub { color: var(--hrl-muted); font-size: .86rem; margin: .15rem 0 0; width: 100%; }
93
+ .hrl-page__actions { margin-left: auto; display: flex; gap: .5rem; }
94
+
95
+ /* Cards */
96
+ .hrl-card {
97
+ background: var(--hrl-card); border: 1px solid var(--hrl-line);
98
+ border-radius: var(--hrl-radius); box-shadow: var(--hrl-shadow);
99
+ padding: .95rem; margin-bottom: .85rem;
100
+ }
101
+ .hrl-card__title { font-weight: 650; font-size: .95rem; margin: 0 0 .6rem; }
102
+ .hrl-card--empty { text-align: center; color: var(--hrl-muted); padding: 1.8rem 1rem; }
103
+ .hrl-grid { display: grid; gap: .85rem; grid-template-columns: 1fr; }
104
+ @media (min-width: 700px) { .hrl-grid--2 { grid-template-columns: 1fr 1fr; } }
105
+
106
+ /* KPI tiles */
107
+ .hrl-kpis { display: grid; grid-template-columns: repeat(2, 1fr); gap: .6rem; margin-bottom: .85rem; }
108
+ @media (min-width: 700px) { .hrl-kpis { grid-template-columns: repeat(4, 1fr); } }
109
+ .hrl-kpi { background: var(--hrl-card); border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius); padding: .7rem .8rem; }
110
+ .hrl-kpi__value { font-size: 1.35rem; font-weight: 700; }
111
+ .hrl-kpi__label { color: var(--hrl-muted); font-size: .78rem; }
112
+
113
+ /* Buttons */
114
+ .hrl-btn {
115
+ display: inline-flex; align-items: center; justify-content: center; gap: .4rem;
116
+ min-height: 44px; padding: .55rem 1rem; border-radius: var(--hrl-radius-sm);
117
+ border: 1px solid var(--hrl-line); background: var(--hrl-card); color: var(--hrl-ink);
118
+ font-size: .9rem; font-weight: 600; cursor: pointer; text-decoration: none;
119
+ }
120
+ .hrl-btn--primary { background: var(--hrl-accent); border-color: var(--hrl-accent); color: var(--hrl-accent-ink); }
121
+ .hrl-btn--danger { color: var(--hrl-bad); border-color: var(--hrl-bad-bg); }
122
+ .hrl-btn--block { width: 100%; }
123
+ .hrl-btn--big { min-height: 58px; font-size: 1.05rem; }
124
+ .hrl-btn[disabled] { opacity: .55; cursor: default; }
125
+
126
+ /* Badges & chips */
127
+ .hrl-badge {
128
+ display: inline-flex; align-items: center; gap: .3rem;
129
+ font-size: .74rem; font-weight: 650; padding: .18rem .55rem;
130
+ border-radius: 999px; background: var(--hrl-info-bg); color: var(--hrl-accent);
131
+ white-space: nowrap;
132
+ }
133
+ .hrl-badge--ok { background: var(--hrl-ok-bg); color: var(--hrl-ok); }
134
+ .hrl-badge--warn { background: var(--hrl-warn-bg); color: var(--hrl-warn); }
135
+ .hrl-badge--bad { background: var(--hrl-bad-bg); color: var(--hrl-bad); }
136
+ .hrl-badge--muted { background: var(--hrl-bg); color: var(--hrl-muted); }
137
+ .hrl-mention { color: var(--hrl-accent); font-weight: 650; }
138
+
139
+ /* Tables (stack to label/value rows on phones) */
140
+ .hrl-table-wrap { overflow-x: auto; }
141
+ .hrl-table { width: 100%; border-collapse: collapse; font-size: .88rem; background: var(--hrl-card); border-radius: var(--hrl-radius); }
142
+ .hrl-table th, .hrl-table td { text-align: left; padding: .6rem .7rem; border-bottom: 1px solid var(--hrl-line); vertical-align: top; }
143
+ .hrl-table th { color: var(--hrl-muted); font-size: .76rem; text-transform: uppercase; letter-spacing: .03em; }
144
+ .hrl-table td.hrl-num, .hrl-table th.hrl-num { text-align: right; font-variant-numeric: tabular-nums; }
145
+ @media (max-width: 640px) {
146
+ .hrl-table--stack thead { display: none; }
147
+ .hrl-table--stack tr { display: block; border-bottom: 6px solid var(--hrl-bg); }
148
+ .hrl-table--stack td { display: flex; justify-content: space-between; gap: .8rem; border-bottom: 1px dashed var(--hrl-line); }
149
+ .hrl-table--stack td::before { content: attr(data-label); color: var(--hrl-muted); font-size: .78rem; }
150
+ .hrl-table--stack td.hrl-num { text-align: left; }
151
+ }
152
+
153
+ /* Forms */
154
+ .hrl-field { margin-bottom: .8rem; }
155
+ .hrl-field label { display: block; font-size: .82rem; font-weight: 600; margin-bottom: .3rem; }
156
+ .hrl-field .hrl-hint { color: var(--hrl-muted); font-size: .76rem; margin-top: .25rem; }
157
+ .hrl-input, .hrl-field input[type], .hrl-field select, .hrl-field textarea {
158
+ width: 100%; min-height: 44px; padding: .55rem .7rem; font: inherit;
159
+ border: 1px solid var(--hrl-line); border-radius: var(--hrl-radius-sm);
160
+ background: var(--hrl-card); color: var(--hrl-ink);
161
+ }
162
+ .hrl-field textarea { min-height: 90px; resize: vertical; }
163
+ .hrl-field--check { display: flex; align-items: center; gap: .5rem; }
164
+ .hrl-field--check input { width: 1.15rem; height: 1.15rem; min-height: 0; }
165
+ .hrl-errors { background: var(--hrl-bad-bg); color: var(--hrl-bad); border-radius: var(--hrl-radius-sm); padding: .7rem .9rem; margin-bottom: .85rem; font-size: .86rem; }
166
+ .hrl-errors ul { margin: .3rem 0 0; padding-left: 1.1rem; }
167
+ .hrl-form-actions { display: flex; gap: .6rem; margin-top: 1rem; }
168
+ .hrl-form-actions .hrl-btn { flex: 1; }
169
+ @media (min-width: 700px) { .hrl-form-actions .hrl-btn { flex: 0 0 auto; min-width: 9rem; } }
170
+
171
+ /* Radio chip row (kudos badges) */
172
+ .hrl-chiprow { display: flex; flex-wrap: wrap; gap: .45rem; }
173
+ .hrl-chiprow label {
174
+ border: 1px solid var(--hrl-line); border-radius: 999px; padding: .4rem .8rem;
175
+ font-size: .8rem; font-weight: 600; cursor: pointer; min-height: 40px; display: inline-flex; align-items: center;
176
+ }
177
+ .hrl-chiprow input { position: absolute; opacity: 0; pointer-events: none; }
178
+ .hrl-chiprow input:checked + span, .hrl-chiprow label:has(input:checked) { background: var(--hrl-info-bg); border-color: var(--hrl-accent); color: var(--hrl-accent); }
179
+
180
+ /* Feed items */
181
+ .hrl-feed__item { display: flex; gap: .7rem; padding: .8rem 0; border-bottom: 1px solid var(--hrl-line); }
182
+ .hrl-feed__item:last-child { border-bottom: 0; }
183
+ .hrl-avatar {
184
+ flex: 0 0 auto; width: 38px; height: 38px; border-radius: 999px;
185
+ background: var(--hrl-info-bg); color: var(--hrl-accent);
186
+ display: inline-flex; align-items: center; justify-content: center; font-weight: 700;
187
+ }
188
+ .hrl-feed__meta { color: var(--hrl-muted); font-size: .76rem; }
189
+ .hrl-feed__body { min-width: 0; flex: 1; overflow-wrap: anywhere; }
190
+
191
+ /* Mention autocomplete menu */
192
+ .hrl-mention-wrap { position: relative; }
193
+ .hrl-mention-menu {
194
+ position: absolute; left: 0; right: 0; z-index: 30;
195
+ background: var(--hrl-card); border: 1px solid var(--hrl-line);
196
+ border-radius: var(--hrl-radius-sm); box-shadow: var(--hrl-shadow);
197
+ max-height: 14rem; overflow-y: auto;
198
+ }
199
+ .hrl-mention-menu button {
200
+ display: block; width: 100%; text-align: left; padding: .6rem .8rem;
201
+ background: none; border: 0; font: inherit; cursor: pointer; min-height: 44px;
202
+ }
203
+ .hrl-mention-menu button:hover, .hrl-mention-menu button.hrl-active { background: var(--hrl-bg); }
204
+
205
+ /* Pagination */
206
+ .hrl-pager { display: flex; align-items: center; justify-content: center; gap: .8rem; margin: 1rem 0; font-size: .88rem; }
207
+ .hrl-pager__info { color: var(--hrl-muted); }
208
+
209
+ /* Definition list */
210
+ .hrl-deflist { display: grid; grid-template-columns: minmax(7rem, auto) 1fr; gap: .35rem .9rem; font-size: .9rem; }
211
+ .hrl-deflist dt { color: var(--hrl-muted); }
212
+ .hrl-deflist dd { margin: 0; overflow-wrap: anywhere; }
213
+
214
+ /* Month grid (attendance) */
215
+ .hrl-mgrid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
216
+ .hrl-mgrid__head { text-align: center; color: var(--hrl-muted); font-size: .68rem; padding: .2rem 0; }
217
+ .hrl-mgrid__day {
218
+ min-height: 44px; border-radius: 7px; background: var(--hrl-card);
219
+ border: 1px solid var(--hrl-line); padding: .2rem .3rem; font-size: .72rem;
220
+ display: flex; flex-direction: column; gap: .1rem; position: relative;
221
+ }
222
+ .hrl-mgrid__day--muted { opacity: .45; }
223
+ .hrl-mgrid__num { font-weight: 650; }
224
+ .hrl-mgrid__tag { font-size: .6rem; color: var(--hrl-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
225
+ .hrl-mgrid__day--present { background: var(--hrl-ok-bg); border-color: transparent; }
226
+ .hrl-mgrid__day--half { background: linear-gradient(135deg, var(--hrl-ok-bg) 50%, var(--hrl-card) 50%); }
227
+ .hrl-mgrid__day--leave { background: var(--hrl-info-bg); border-color: transparent; }
228
+ .hrl-mgrid__day--holiday { background: var(--hrl-warn-bg); border-color: transparent; }
229
+ .hrl-mgrid__day--weekend { background: var(--hrl-bg); }
230
+ .hrl-mgrid__day--absent { background: var(--hrl-bad-bg); border-color: transparent; }
231
+ .hrl-mgrid__flag { position: absolute; top: 3px; right: 4px; width: 7px; height: 7px; border-radius: 999px; background: var(--hrl-bad); }
232
+ .hrl-legend { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .6rem; }
233
+
234
+ /* Utility */
235
+ .hrl-muted { color: var(--hrl-muted); }
236
+ .hrl-small { font-size: .8rem; }
237
+ .hrl-mt { margin-top: .85rem; }
238
+ .hrl-right { margin-left: auto; }
239
+ .hrl-row { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; }