Metadata-Version: 2.4
Name: coffeehouse-ui
Version: 0.13.0
Summary: Shared UI components, theme, and static assets for the Coffee House ecosystem
Author-email: Ruben Sukiasyan <rubsksn@gmail.com>
License: MIT License
        
        Copyright (c) 2026 Ruben Sukiasyan
        
        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 OF OTHER DEALINGS IN THE
        SOFTWARE.
        
Project-URL: Homepage, https://github.com/coffeehouse-tools/coffeehouse-ui
Project-URL: Repository, https://github.com/coffeehouse-tools/coffeehouse-ui
Project-URL: Issues, https://github.com/coffeehouse-tools/coffeehouse-ui/issues
Project-URL: Changelog, https://github.com/coffeehouse-tools/coffeehouse-ui/blob/master/CHANGELOG.md
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Operating System :: OS Independent
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Typing :: Typed
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: babel<3,>=2.18
Requires-Dist: jinja2>=3.1.0
Requires-Dist: starlette>=0.38.0
Provides-Extra: dev
Requires-Dist: pytest==9.2.0; extra == "dev"
Requires-Dist: pytest-cov==7.1.0; extra == "dev"
Requires-Dist: ruff==0.17.3; extra == "dev"
Requires-Dist: mypy==2.4.1; extra == "dev"
Requires-Dist: bandit==1.9.4; extra == "dev"
Requires-Dist: pip-audit==2.10.1; extra == "dev"
Requires-Dist: pre-commit==4.7.0; extra == "dev"
Requires-Dist: setuptools==84.2.1; extra == "dev"
Dynamic: license-file

# coffeehouse-ui

Shared UI package for the [Coffee House digital ecosystem](https://github.com/rubencfh/coffeehouse-ecosystem). Pip-installable Python package providing Jinja2 base templates, shared components, CSS theme, JS utilities, brand assets, and the **unified permission UX** used by Digi, Grind, and Beany (`compute_effective(dept ∪ grants − revokes)` resolver + three shared Jinja macros for the dept-matrix / role-tri-state / simulate admin pages).

## Quick Start

Install from PyPI, pinned exactly, in your app's `requirements.txt`:

```
coffeehouse-ui==0.11.0
```

Integrate in your FastAPI/Starlette app:

```python
from coffeehouse_ui import TEMPLATE_DIR as UI_TEMPLATE_DIR, STATIC_DIR as UI_STATIC_DIR
from starlette.templating import Jinja2Templates
from starlette.staticfiles import StaticFiles

templates = Jinja2Templates(directory=[str(app_templates_dir), str(UI_TEMPLATE_DIR)])
templates.env.globals["app_name"] = "YourApp"
app.mount("/ui", StaticFiles(directory=str(UI_STATIC_DIR)), name="ui_static")
```

Your app's `base.html` extends `ui_base.html`:

```html
{% extends "ui_base.html" %}
{% block title %}My Page{% endblock %}
{% block content %}...{% endblock %}
```

## What's Included

### Templates

- **`ui_base.html`** — Canonical base template with Inter font, DaisyUI, Tailwind, custom theme, dark mode, HTMX, skip-to-content, toast container, CSRF/toast/loading JS
- **12 shared components** — navbar, sidebar, app_switcher, flash, page_header, data_table, empty_state, stat_card, pagination, form_field, confirm_modal, loading

### Static Assets

- **CSS**: `coffeehouse.min.css` (purged Tailwind 3 + DaisyUI 4, 18KB gzip) + `coffeehouse-theme.css`
- **JS**: `theme.js`, `toast.js`, `loading.js`, `csrf.js`, `session.js` (marks timer-fired HTMX requests with `X-Background-Request: true`, so an app answers a poll on a lapsed session with an empty 204 instead of navigating the page), `confirm.js` (submitter-aware `data-confirm` dialog — pairs with the `confirm_dialog()` macro in `templates/macros/confirm.html`), `htmx.min.js` (v2.0.4), `culori.min.js` (color conversion for Auth brand admin)
- **Images**: `favicon.svg` (coffee cup)

### Theme

Custom DaisyUI themes `coffeehouse` (light) and `coffeehouse-dark` (dark) with a warm espresso/brown palette. Dark mode toggle persists to localStorage.

The sans stack is `Inter, "Noto Sans Armenian", …`. Inter has no Armenian glyphs; both Noto Sans Armenian faces (`static/fonts/`, OFL) are declared in `coffeehouse-theme.css` with `unicode-range: U+0530-058F, U+FB13-FB17`, so a browser downloads one only on a page that renders Armenian text. Never preload them.

## Translations

`coffeehouse_ui.i18n` translates an app's UI per request with gettext catalogs (Babel). English is the source text and the default; the supported locales are `SUPPORTED = ("en", "hy")`. Each person's choice is saved by Auth and carried in the session; the app reads it and hands it to the middleware.

**An app that never calls `install` is unaffected.** Every package template renders byte for byte as it did in v0.12.1 without the i18n extension (`tests/unit/test_i18n_noop.py` compares every template against outputs captured from v0.12.1, both without `install` and with `install` at `en`). The guarantee is for Starlette's default `Jinja2Templates` environment, which every consumer uses: with `trim_blocks` or `lstrip_blocks` switched on, some whitespace around the new template tags differs.

### Opting in

```python
from coffeehouse_ui.i18n import LocaleMiddleware, install

install(
    templates.env,  # the app's Jinja environment
    translations_dir=APP_DIR / "translations",
    domain="messages",  # default
    default_locale="en",  # default
    switch_url=f"{AUTH_SERVICE_URL}/me/language",  # language row in the user menu; no ? or #
)


def language_of(request) -> str | None:  # the app reads its own session
    user = current_user_or_none(request)
    return user.get("language") if user else None


app.add_middleware(LocaleMiddleware, language_of=language_of)  # first call = innermost
```

- `switch_url` must be a plain URL (no `?` or `#`): the menu row appends `?next=<current page>`. `install` refuses anything else. It also clears the environment's template cache, so a template rendered before `install` picks up the translation.
- `install` is process-wide: the catalogs live at module level so Python code (lazy label dictionaries, toasts, PDF chrome) translates without a Jinja environment in hand. One app per process; a second call replaces the catalogs for every environment.
- `install` adds `jinja2.ext.i18n`, installs newstyle `gettext` / `ngettext` / `pgettext` / `npgettext` (and `_`) bound to the request's locale, and sets the globals `locale` (read at render time; `ui_base.html` puts it on `<html lang>`), `i18n_switch_url` and `ch_i18n` (used by the package's own helpers).
- `LocaleMiddleware` is pure ASGI (a `BaseHTTPMiddleware` would not carry the context variable to the endpoint). `language_of` must be a sync function: an async one is refused at start, since its coroutine would never be awaited. A value outside `SUPPORTED`, `None`, or an exception from `language_of` gives the default locale (the middleware's `default_locale` if passed, else the one `install` set); the first exception is logged at WARNING with its traceback, later ones at DEBUG. The package never reads the session itself and does not depend on coffeehouse-common.
- Python code imports `gettext` / `_`, `ngettext`, `pgettext`, `npgettext` and `get_locale` from `coffeehouse_ui.i18n`. Module-level strings (label dictionaries) use `lazy_gettext` / `lazy_pgettext`, which re-evaluate at every render.

### Catalog layout

```
<app>/translations/messages.pot                 # extracted from the app's source
<app>/translations/hy/LC_MESSAGES/messages.po   # Armenian; required (a header-only file is enough to start)
coffeehouse_ui/translations/coffeehouse_ui.pot  # the package's chrome (Logout, Cancel, pagination, ...)
coffeehouse_ui/translations/hy/LC_MESSAGES/coffeehouse_ui.po
```

Only `.po` / `.pot` are committed; `install` compiles them in memory at start, so there is no `.mo` file and no build step. Entries marked `#, fuzzy` are left out, so they render English until someone confirms them. The app's catalog is merged over the package's: an app may reword shared chrome by translating the same msgid. A missing app catalog for a supported locale is a startup error, not a silent English fallback.

### What a translation may contain (app catalogs)

Newstyle `_()` and `{% trans %}` return `Markup`: **an app's catalog text, msgid and msgstr alike, is trusted HTML and is not escaped**, while named placeholders (`_("Hello %(name)s", name=user.name)`) are escaped. So app catalogs hold plain sentences: no `&amp;` or other entities, never user data in a msgid, and markup only when the sentence really is HTML. A message is a whole sentence with named placeholders, never assembled from fragments; a short word with several meanings carries a context (`pgettext("audit", "Close")`).

### The package's own templates

Package templates must still render in an environment without the i18n extension, where `{% trans %}` does not parse and `_` is undefined, so they never use either. They use the macros in `templates/macros/i18n.html`:

```jinja
{% from "macros/i18n.html" import ch_t, ch_nt, ch_data %}
{{ ch_t("Logout") }}
{{ ch_t("from dept: %(dept)s", dept=slug) }}
{{ ch_nt("%(num)d role", "%(num)d roles", n) }}
<div id="x"{{ ch_data("data-empty-label", "(empty)") }}>
```

Without `install` they output the English literal; with it they translate through `ch_i18n`, which returns a plain `str`. Either way the result is escaped exactly as the literal was, which is why they are not `_()` (whose `Markup` would render `Login & Users` differently). `ch_data` emits the attribute only when the current locale translates the message, so a script keeps its English fallback and the markup at `en` is unchanged. Where one sentence cannot reproduce the old markup byte for byte (a multi-line paragraph with a link), the template keeps the old English markup verbatim and switches to a whole-sentence block only when `ch_i18n.translates(msgid)` is true.

Refresh the package catalog after changing a package string (keywords come from `babel.cfg`):

```bash
pybabel extract -F babel.cfg --sort-output --project coffeehouse-ui -o coffeehouse_ui/translations/coffeehouse_ui.pot .
pybabel update -D coffeehouse_ui -d coffeehouse_ui/translations -i coffeehouse_ui/translations/coffeehouse_ui.pot
```

### Checks for an app's tests

- `pot_drift(pot_path, root=..., method_map=..., options_map=..., keywords=...) -> (missing, stale)`: messages a fresh extraction finds that the `.pot` lacks, and the reverse, compared as `(msgctxt, msgid, msgid_plural)` with locations ignored. `read_mapping("babel.cfg")` returns the `(method_map, options_map)` pair.
- `catalog_report(po_path) -> (untranslated, fuzzy)`: msgid lists; fail a test on the first, report the second.

## CSS Build

CSS is built from `src/input.css` using Tailwind 3 + DaisyUI 4. The Tailwind config scans templates across all ecosystem apps.

```bash
npm install          # First time only
npm run build        # Rebuild after adding new Tailwind classes
npm run watch        # Watch mode for development
```

Rebuild when a new Tailwind utility class is used in any app template that wasn't previously in the CSS bundle. You rarely need to do this by hand any more: coffeehouse-ecosystem's daily `css-rebuild-cron` rebuilds against every consumer's master and opens a `chore(css): rebuild shared stylesheet (vX.Y.Z)` PR here when the bundle differs; on merge `css-rebuild-release.yml` tags it, PyPI publishes, and the consumer pins bump automatically.

## Template Linter

```bash
python scripts/lint_templates.py --all
```

Checks all ecosystem templates for CDN links, missing `role="alert"`, missing `scope="col"`, and hardcoded color classes.

## Project Structure

```
coffeehouse_ui/
├── __init__.py              # Exports TEMPLATE_DIR, STATIC_DIR, __version__
├── i18n/                    # Translation: install, LocaleMiddleware, gettext callables, checks
├── translations/            # The package's chrome catalog (.pot + hy .po)
├── templates/
│   ├── ui_base.html
│   ├── macros/i18n.html     # ch_t / ch_nt / ch_data for package templates
│   └── components/          # 12 shared Jinja2 components
└── static/
    ├── css/                 # Built Tailwind + theme CSS
    ├── fonts/               # Inter, Noto Sans Armenian (woff2 + licences)
    ├── js/                  # Theme, toast, loading, CSRF, HTMX
    └── img/                 # Favicon
```

## Consumer Apps

| App | Repo |
|-----|------|
| Auth | [rubencfh/coffeehouse-auth](https://github.com/rubencfh/coffeehouse-auth) |
| Beany/KMS | [rubencfh/Beany](https://github.com/rubencfh/Beany) |
| Digi | [rubencfh/Digi](https://github.com/rubencfh/Digi) |
| Grind | [rubencfh/Grind](https://github.com/rubencfh/Grind) |
| CHQR | [rubencfh/CHQR](https://github.com/rubencfh/CHQR) |

## Development

First-time setup:

```bash
pip install -e ".[dev]"
pip install -r requirements-dev.txt   # pulls coffeehouse-common[testing] + pinned tooling
pre-commit install                    # wires ruff-format + ruff + hygiene hooks on each commit
```

Run the checks that CI runs:

```bash
pytest                                  # unit + integration tests with coverage
ruff format --check . && ruff check .   # format + lint
mypy coffeehouse_ui                     # lenient typecheck
bandit -c pyproject.toml -r coffeehouse_ui scripts -ll  # SAST
```

`pre-commit run --all-files` runs the on-commit subset in one go. gitleaks runs in CI only (Windows Application Control blocks the Go-built local hook; install the native binary manually if you want local secret scanning).

The full pipeline (lint / typecheck / pytest w/ coverage / secret-scan / SAST / SCA) runs on every push and PR via `.github/workflows/ci.yml`, which is a thin caller of `rubencfh/coffeehouse-ecosystem/.github/workflows/reusable-python-ci.yml@master`. **As of the TBD cutover (2026-04-22), the suite is a merge gate on `master`**: 6 CI jobs + CodeRabbit review = 7 required status checks. Direct `git push origin master` is blocked; changes land through short-lived feature branches + PRs.

## Branch Strategy — Trunk-Based Development

- `master` is the single long-lived branch (renamed from `dev` on 2026-04-22 for ecosystem-wide naming consistency; previously this repo shipped from `dev`).
- Every change goes through a short-lived feature branch off `master` + PR + squash merge. Feature branches auto-delete on merge.
- Tag releases as `v0.x.0` on `master` for consumer apps to pin.
- See `agents/shared/50-dev-workflow.md` and `agents/shared/97-pr-workflow.md` for the full flow.

## Version

Current: **0.3.1**

### Brand API (optional)

Set `templates.env.globals["auth_service_url"]` to your Auth base URL (e.g. `https://auth.coffeehouse.tools`). `ui_base.html` then loads active brand CSS and images from `GET {auth_service_url}/api/brand/active` on each session (cached in `sessionStorage` for 30 seconds). Omit or leave empty if you do not use the shared brand feature.
