qss 0.6.0 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +345 -175
- data/QSS-MANIFESTO.md +16 -4
- data/README.md +62 -166
- data/app/assets/javascripts/qss/qss-engine.js +16 -6
- data/app/assets/stylesheets/qss/qss_core.css +12024 -0
- data/app/assets/stylesheets/qss/qss_core.scss +15 -0
- data/config/grammar.yml +6 -0
- data/config/integrity.json +2 -2
- data/docs/DESIGN_HISTORY.md +882 -0
- data/docs/HOWTO.md +269 -0
- data/exe/qss-core-render-check +38 -0
- data/exe/qss-engine-render-check +8 -2
- data/lib/qss/qss_auditor.rb +5 -1
- data/lib/qss/version.rb +1 -1
- metadata +12 -8
data/docs/HOWTO.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# How to use QSS
|
|
2
|
+
|
|
3
|
+
A step-by-step walkthrough from installing the gem to a fully audited layout with overlays and persistent chrome.
|
|
4
|
+
For the pitch and a 60-second taste, see [README.md](../README.md). For *why* QSS looks the way it does — including
|
|
5
|
+
the approaches that were tried and rejected — see [DESIGN_HISTORY.md](DESIGN_HISTORY.md).
|
|
6
|
+
|
|
7
|
+
## Step 1: Install
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem "qss"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The gem's Rails engine puts QSS's assets on your asset pipeline (`qss/qss_core.css`, `qss/qss_core.scss`,
|
|
15
|
+
`qss/qss-engine.js`). Everything else in QSS — the auditors, configuration — works in plain Ruby too; Rails is only
|
|
16
|
+
needed for the asset pipeline.
|
|
17
|
+
|
|
18
|
+
## Step 2: Load the stylesheet
|
|
19
|
+
|
|
20
|
+
The simplest path, QSS's neutral defaults:
|
|
21
|
+
|
|
22
|
+
```erb
|
|
23
|
+
<%= stylesheet_link_tag "qss/qss_core" %>
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
To use your own settings (your palette, a different portal id, and so on — see Step 5), set your values first and
|
|
27
|
+
import core after; core's settings are `!default`, so yours win. Put `QSS.stylesheets_path` on your Sass load path
|
|
28
|
+
so `qss/qss_core` resolves:
|
|
29
|
+
|
|
30
|
+
```scss
|
|
31
|
+
// app/assets/stylesheets/application.scss
|
|
32
|
+
@import "qss_variables"; // generated from your config by qss-sync -- see Step 5
|
|
33
|
+
@import "qss/qss_core";
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
Sass.compile("app/assets/stylesheets/application.scss",
|
|
38
|
+
load_paths: ["app/assets/stylesheets", QSS.stylesheets_path])
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Step 3: Write your first layout
|
|
42
|
+
|
|
43
|
+
The canvas id is fixed: `#qss-canvas` is not configurable, so a document has exactly one top-level QSS canvas. Two
|
|
44
|
+
independent QSS instances nested in the same page would collide on it; isolate them in separate documents (an
|
|
45
|
+
`<iframe>`) instead. This is a known constraint, not something QSS will build support for.
|
|
46
|
+
|
|
47
|
+
### The canvas, `<body>`, and everything else
|
|
48
|
+
|
|
49
|
+
QSS resets `html, body` globally (`margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden;`), so the
|
|
50
|
+
canvas's own `height: 100%` has something real, non-zero to measure against. That reset only reaches as far as
|
|
51
|
+
`<body>` itself, though: if you nest the canvas inside other wrapper elements — a layout's own container divs,
|
|
52
|
+
say — size those wrappers too (`height: 100%` down the chain), or just put `#qss-canvas` directly inside `<body>`,
|
|
53
|
+
which is the common case and needs nothing extra. Both `<body>` and the canvas clip rather than scroll by
|
|
54
|
+
default (`overflow: hidden`) — QSS assumes a full-bleed, app-style viewport, not a scrolling document. Opt into
|
|
55
|
+
scrolling deliberately with `qss-scroll` or `qss-scroll-smooth` wherever you actually want it.
|
|
56
|
+
|
|
57
|
+
Everything *outside* `#qss-canvas` (and outside the optional overlay portal, Step 7) is completely untouched by
|
|
58
|
+
QSS. Every single rule QSS generates is scoped to being a descendant of one of those two roots, so anything else
|
|
59
|
+
you put in `<body>` — a cookie banner, an analytics widget, a completely separate part of the page — renders under
|
|
60
|
+
ordinary CSS exactly as if QSS weren't installed at all.
|
|
61
|
+
|
|
62
|
+
The same is true one level down, *inside* the canvas: only elements that actually carry a QSS class (`qss`, or any
|
|
63
|
+
`qss-` prefixed utility) are ever touched. QSS's reset specifically targets `.qss`-classed elements
|
|
64
|
+
(`:where(.qss:not(.foreign *))` in the compiled CSS) — nothing broader — so a plain `<div>` with no QSS classes at
|
|
65
|
+
all, dropped anywhere inside the canvas, renders however your own CSS or content framework says it should. For
|
|
66
|
+
content you want to *guarantee* stays untouched even if it happens to collide with a `qss-`-prefixed class name
|
|
67
|
+
from some third-party library, wrap it in `class="foreign"` (or mark the one offending element
|
|
68
|
+
`qss-payload-override`) — QSS's explicit escape hatch, rather than relying on it simply never reaching there.
|
|
69
|
+
|
|
70
|
+
Every element QSS positions carries class `qss` (an **actor**) and lives inside the canvas. An actor's id is its
|
|
71
|
+
**noun** — its identity — and follows `{qualifier}-{baseObject}`: the base object is an HTML tag, the qualifier
|
|
72
|
+
says which one. Declare each qualifier once, in a registry, recording what it adds to its base:
|
|
73
|
+
|
|
74
|
+
```erb
|
|
75
|
+
<div id="qss-canvas">
|
|
76
|
+
<nav id="main-nav" class="qss qss-relative qss-contain-layout qss-w[200] qss-h-full"> ... </nav>
|
|
77
|
+
<section id="content-section" class="qss qss-relative qss-contain-layout qss-w[800] qss-h-full"> ... </section>
|
|
78
|
+
</div>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
```yaml
|
|
82
|
+
# config/qss_registry.yml (auditor.registry_path in your config, Step 5)
|
|
83
|
+
types:
|
|
84
|
+
main-nav:
|
|
85
|
+
extends: nav
|
|
86
|
+
content-section:
|
|
87
|
+
extends: section
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Structural frames (`nav`, `main`, and similar) carry `qss-contain-layout`, which both isolates their internal
|
|
91
|
+
layout and clips their own overflow by default — a frame's content can't spill out and cover its neighbors. This
|
|
92
|
+
example audits fully green (Step 4).
|
|
93
|
+
|
|
94
|
+
Everything an actor needs to be addressable is a single explicit bracket value: `qss-w[200]` is 200 qnt wide —
|
|
95
|
+
20% of the canvas, at any screen size, since 1 qnt is always `canvas width / 1000`, computed in pure CSS. The same
|
|
96
|
+
pattern covers position (`qss-pos-x[N]`, `qss-pos-y[N]`, `qss-pos-r[N]`, `qss-pos-b[N]`), size (`qss-w[N]`,
|
|
97
|
+
`qss-h[N]`, `qss-min-w[N]`, `qss-min-h[N]`), spacing (`qss-gap[N]`, `qss-p[N]`, `qss-px[N]`, `qss-py[N]`) and
|
|
98
|
+
borders/radius. Percentage-of-parent variants exist too (`qss-w-pct[N]`, `qss-pos-y-pct[N]`, ...) for when you want
|
|
99
|
+
a fraction of the *immediate* containing block instead of the canvas.
|
|
100
|
+
|
|
101
|
+
An actor must always declare both its width and height, in one of these units — never left to shrink-to-fit. Every
|
|
102
|
+
actor is also a CSS size container (`container-type: inline-size`, needed for qnt itself to resolve), and per the
|
|
103
|
+
CSS Containment spec that rules out content-based `auto` sizing in either axis in any browser that implements
|
|
104
|
+
container queries correctly. An actor with no size opts into this deliberately with `qss-content-driven`. See
|
|
105
|
+
[DESIGN_HISTORY.md](DESIGN_HISTORY.md) #16 for why.
|
|
106
|
+
|
|
107
|
+
## Step 4: Audit your views
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
result = QSS::QssNativeAuditor.audit_file("app/views/layouts/application.html.erb")
|
|
111
|
+
result[:status] # => "green", "orange" or "red"
|
|
112
|
+
result[:issues] # => [{ type:, message:, severity: }, ...] (plus code: on most identity and unit findings)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
RED means a rule is violated: fix the markup, or reconsider the rule. ORANGE is a judgment call. Rules include:
|
|
116
|
+
every actor declares its width and height (or opts out with `qss-content-driven`); structure carries no raw CSS
|
|
117
|
+
lengths (sizes are in qnt or canvas percentages); no `!important`; ids follow the `{qualifier}-{baseObject}`
|
|
118
|
+
grammar; every class literally resolves to a rule in the compiled stylesheet, not just a known shape; a `qss`-marked
|
|
119
|
+
actor carries no class (from a content framework such as Tailwind) that resolves, in `auditor.drywall_stylesheet_path`,
|
|
120
|
+
to a rule setting a structural property (`display`, `position`, sizing, `flex*`/`grid*`, `gap`, `z-index`, `order`)
|
|
121
|
+
— checked by CSS property, so it works for any content framework with no fork.
|
|
122
|
+
|
|
123
|
+
`QSS::QssAuditor` is the older class-whitelist auditor: it predates the bracket vocabulary (it reports `qss-w[200]`
|
|
124
|
+
as unknown) and is kept only for projects that still use its whitelist. Use `QssNativeAuditor`.
|
|
125
|
+
|
|
126
|
+
**Three tiers of stylesheet.** Class resolution reads core's own `qss_core.css` (always, verified against the
|
|
127
|
+
manifest shipped with the gem, raising `QSS::CoreIntegrityError` on a mismatch), any sealed tool stylesheets
|
|
128
|
+
(`auditor.tool_stylesheets`, sealed by the tool's own build with `qss-seal`, raising `QSS::ToolIntegrityError` if
|
|
129
|
+
unsealed or changed), and your own compiled output (`auditor.host_stylesheets`, `stylesheet_path`, or
|
|
130
|
+
`QSS.configure(host_stylesheets: -> { ... })`; a Rails host defaults to `app/assets/builds/*.css`).
|
|
131
|
+
|
|
132
|
+
Appearance classes (colors, type styling, shadows, filters) are **deprecated**: the auditor reports each as orange
|
|
133
|
+
`deprecated_decorative`, since appearance belongs to whatever fills Drywall — your content framework, not QSS.
|
|
134
|
+
QSS's own quantized bracket scale (`qss-border[2]`, `qss-text-size[14]`) is unaffected, since those are structural
|
|
135
|
+
(a border's width, text's footprint), not decorative. A Drywall stylesheet compiled with the `qss` prefix is a red
|
|
136
|
+
`drywall_prefix_collision` — every QSS class is prefixed `qss-` specifically so no content framework sharing the
|
|
137
|
+
canvas can mistake its own unprefixed utility for QSS's.
|
|
138
|
+
|
|
139
|
+
## Step 5: Configure QSS for your project
|
|
140
|
+
|
|
141
|
+
QSS starts neutral. Declare your own settings in one YAML file and tell QSS where it is:
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
# config/initializers/qss.rb
|
|
145
|
+
QSS.configure(root: Rails.root, config: "config/qss_config.yml")
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
# config/qss_config.yml -- every key is optional; see the gem's config/grammar.yml for the defaults
|
|
150
|
+
config:
|
|
151
|
+
portal_root: "#qss-portal" # overlays and chrome render here (any one #id; default #qss-portal); "" = rules scoped to the canvas only
|
|
152
|
+
palette:
|
|
153
|
+
brand-main: "#0f1115"
|
|
154
|
+
group_names: [menu] # named groups for group-*:hover style classes
|
|
155
|
+
auditor:
|
|
156
|
+
id_prefix: "app-" # optional project id prefix
|
|
157
|
+
registry_path: "config/qss_registry.yml"
|
|
158
|
+
sync_targets:
|
|
159
|
+
variables: app/assets/stylesheets/_qss_variables.scss
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Relative paths resolve against the declared `root`, never the working directory.
|
|
163
|
+
|
|
164
|
+
Generate your SCSS settings from that config whenever it changes:
|
|
165
|
+
|
|
166
|
+
```sh
|
|
167
|
+
QSS_CONSUMER_CONFIG=config/qss_config.yml bundle exec qss-sync
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This writes `_qss_variables.scss` (the `sync_targets.variables` path, imported in Step 2). Without
|
|
171
|
+
`QSS_CONSUMER_CONFIG` it reads `config/qss_config.yml` from the current directory.
|
|
172
|
+
|
|
173
|
+
## Step 6: Add the optional JS engine (if you want it)
|
|
174
|
+
|
|
175
|
+
The compiled CSS from Steps 2–3 is already fully functional with zero JavaScript. The engine adds three runtime
|
|
176
|
+
conveniences on top:
|
|
177
|
+
|
|
178
|
+
- Rules for bracket classes the compiled CSS doesn't precompute (there's no combinatorial way to precompile every
|
|
179
|
+
possible value of a composite blueprint like `qss-Button[200|80]` — see Step 8's note on why `qss-elastic-y`
|
|
180
|
+
took a different shape instead).
|
|
181
|
+
- Exporting measured element edges as CSS variables.
|
|
182
|
+
- Naming each actor's CSS container after its own id (`container-name`, inside both the canvas and the portal), so
|
|
183
|
+
Drywall content inside it can use a named container-query variant — Tailwind's `@sm/<frame-name>:`, for example
|
|
184
|
+
— to react to that specific frame's own width, independent of the canvas or any sibling.
|
|
185
|
+
|
|
186
|
+
```erb
|
|
187
|
+
<%= javascript_include_tag "qss/qss-engine", defer: true %>
|
|
188
|
+
<script>
|
|
189
|
+
document.addEventListener("DOMContentLoaded", () => { QSSEngine.startPulse(); QSSEngine.startObserver(); });
|
|
190
|
+
</script>
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
While developing, `QSSEngine.auditPositioning()` lists every element that is `position: fixed` or `sticky` without
|
|
194
|
+
QSS owning it — a loophole no static check can see, since a Drywall `fixed` utility only becomes a position once a
|
|
195
|
+
stylesheet resolves it. Frames, the overlay portal, `foreign` content and the browser's native top layer are
|
|
196
|
+
exempt. It runs only when you call it; `QSSEngine.setPortal("#qss-overlays")` tells it where your portal root is if
|
|
197
|
+
it isn't `#qss-portal`.
|
|
198
|
+
|
|
199
|
+
## Step 7: Overlays — modals, dropdowns, toasts
|
|
200
|
+
|
|
201
|
+
A modal, dialog, menu or popover belongs outside the canvas entirely, in the **overlay portal root**: the element
|
|
202
|
+
whose id is your configured `portal_root` (any one `#id` selector; `#qss-portal` when none is set), matched
|
|
203
|
+
exactly — no prefix or substring guessing. Core gives it its own geometry automatically (a fixed, viewport-sized
|
|
204
|
+
layer, `pointer-events: none` on the root and `auto` on its direct children, its own `--qnt` resolved the same way
|
|
205
|
+
as the canvas's), so write no QSS classes on the root element itself — they match nothing there — and place it
|
|
206
|
+
after the canvas in the document so it paints above it with no `z-index` at all.
|
|
207
|
+
|
|
208
|
+
Prefer the browser's native top layer: a `<dialog>` opened with `showModal()`, or any element carrying the
|
|
209
|
+
`popover` attribute. The browser then renders it above everything, genuinely with no `z-index`. A plain element
|
|
210
|
+
placed directly in the portal root (the fallback path) is allowed, but the auditor reports it as orange
|
|
211
|
+
`top_layer_preferred` until you mark it `qss-overlay-fallback`. A `z-index` is permitted only on the portal root
|
|
212
|
+
and inside it (to order one fallback overlay above another, or rely on DOM order alone); anywhere else, Portal
|
|
213
|
+
Integrity makes it red.
|
|
214
|
+
|
|
215
|
+
## Step 8: Persistent chrome — nav bars, toolbars, sidebars
|
|
216
|
+
|
|
217
|
+
Global nav, a file-tree explorer, a toolbar pinned to a screen edge — anything genuinely edge-docked and
|
|
218
|
+
load-bearing that must never crop as the viewport reshapes — isn't canvas content, and shouldn't use canvas
|
|
219
|
+
position classes (`qss-pos-y[N]` and friends). Dock it as a direct child of the same portal root from Step 7:
|
|
220
|
+
|
|
221
|
+
```html
|
|
222
|
+
<div id="qss-portal">
|
|
223
|
+
<nav id="chrome-sidebar" class="qss qss-w[64]" style="left: 0; top: 0; height: 100vh;"> ... </nav>
|
|
224
|
+
</div>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
Three rules, used together:
|
|
228
|
+
|
|
229
|
+
1. **The chrome element's own placement uses real CSS edges** (`top: 0; left: 0; height: 100vh;`), never a qnt
|
|
230
|
+
position class — it's always exactly at the true edge by definition, at any viewport shape.
|
|
231
|
+
2. **Each element's own size still uses qnt** (`qss-w[N]`/`qss-h[N]`), scaling proportionally with the real
|
|
232
|
+
viewport, visually consistent with the rest of the UI at any resolution.
|
|
233
|
+
3. **Position *within* the chrome** (a button cluster sitting 70–80% down a nav bar) uses `qss-elastic-y-*`
|
|
234
|
+
(Step 9) or `*-pct[N]` plus ordinary flexbox, nested one level deeper.
|
|
235
|
+
|
|
236
|
+
There is deliberately no second, separate root for this: persistent chrome and ephemeral overlays share
|
|
237
|
+
`#qss-portal`, because stacking there is pure DOM paint order with no `z-index` (Step 7) — a native top-layer
|
|
238
|
+
overlay always paints above everything regardless of which root anything lives in, and a modal or popover is
|
|
239
|
+
meant to take over the user's entire attention, so chrome floating above it is never the right default. The one
|
|
240
|
+
contract this depends on: **always append** new overlays to the portal, never insert at a fixed index, so
|
|
241
|
+
later-added overlays keep painting over earlier, static chrome. See
|
|
242
|
+
[DESIGN_HISTORY.md](DESIGN_HISTORY.md) #17.3.
|
|
243
|
+
|
|
244
|
+
## Step 9: Elastic spacing — gaps that flex, without breaking proportion
|
|
245
|
+
|
|
246
|
+
Most spacing should stay rigidly proportional to the canvas (`qss-pos-y[N]`, Step 3) — that's the default, and
|
|
247
|
+
it's what keeps a whole composition moving together as one image at any screen size. But some gaps are more like
|
|
248
|
+
a margin than a structural relationship: the headroom above a centered cluster, say, which can reasonably grow or
|
|
249
|
+
shrink a bit as the frame's shape changes, the way a video's headroom above a subject's head is allowed to vary
|
|
250
|
+
with the frame shape while the subject's own position stays fixed (the rule-of-thirds analogy this is named for).
|
|
251
|
+
|
|
252
|
+
For those, stack four classes on one element — three value-setters plus a trigger:
|
|
253
|
+
|
|
254
|
+
```html
|
|
255
|
+
<div class="qss qss-elastic-y-min[20] qss-elastic-y-pct[100] qss-elastic-y-max[80] qss-elastic-y"> ... </div>
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
This computes `top: clamp(20 * qnt, 10%, 80 * qnt)` against the element's immediate containing block: it tracks
|
|
259
|
+
10% of the parent's actual height, but never drops below 20 qnt or rises above 80 qnt, however that parent
|
|
260
|
+
reshapes. (It's four classes rather than one `qss-elastic-y[min|pct|max]` bracket because a three-parameter
|
|
261
|
+
bracket can't be precompiled — it would need every min×pct×max combination generated in advance. Composing single-
|
|
262
|
+
value classes, the same idiom the rest of the grammar already uses, keeps this working with zero JavaScript.) See
|
|
263
|
+
[DESIGN_HISTORY.md](DESIGN_HISTORY.md) #17.2 for the full reasoning.
|
|
264
|
+
|
|
265
|
+
## Where to go from here
|
|
266
|
+
|
|
267
|
+
- [QSS-MANIFESTO.md](../QSS-MANIFESTO.md) — the full grammar: nouns, adjectives, parametric blueprints.
|
|
268
|
+
- [DESIGN_HISTORY.md](DESIGN_HISTORY.md) — why QSS looks the way it does, including what was tried and discarded.
|
|
269
|
+
- [CHANGELOG.md](../CHANGELOG.md) — what changed, release by release.
|
data/exe/qss-core-render-check
CHANGED
|
@@ -41,10 +41,14 @@ HTML = <<~HTML
|
|
|
41
41
|
<div id="host" class="qss host-static"></div>
|
|
42
42
|
<div class="foreign"><div id="in-foreign" class="qss"></div></div>
|
|
43
43
|
<div id="nearer-container" class="qss qss-w[100] qss-h[10]" style="container-type: inline-size"><div id="deep" class="qss"></div></div>
|
|
44
|
+
<div id="elastic-parent" class="qss qss-w[400] qss-h[300]" style="position: relative;">
|
|
45
|
+
<div id="elastic-actor" class="qss qss-w[50] qss-h[50] qss-elastic-y-min[20] qss-elastic-y-pct[100] qss-elastic-y-max[80] qss-elastic-y"></div>
|
|
46
|
+
</div>
|
|
44
47
|
</div>
|
|
45
48
|
<div id="qss-portal" class="qss-absolute qss-inset-0">
|
|
46
49
|
<div id="portal-actor" class="qss qss-w[640] qss-h[420] qss-pos-r[16] qss-pos-b[52]"></div>
|
|
47
50
|
<div id="portal-plain" class="host-static"></div>
|
|
51
|
+
<div id="chrome-sidebar" class="qss qss-w[64]" style="left: 0; top: 0; height: 100vh;"></div>
|
|
48
52
|
</div>
|
|
49
53
|
</body></html>
|
|
50
54
|
HTML
|
|
@@ -145,6 +149,25 @@ Dir.mktmpdir do |dir|
|
|
|
145
149
|
puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", label, detail)
|
|
146
150
|
end
|
|
147
151
|
|
|
152
|
+
# Persistent chrome docked in the portal (DESIGN_HISTORY #17.3, the README "Persistent chrome" convention):
|
|
153
|
+
# real-edge placement (left:0, top:0, height:100vh), qnt-sized width, at two very different viewport heights --
|
|
154
|
+
# proving it stays pinned with zero cropping and its width tracks the real viewport, not a fixed root.
|
|
155
|
+
[ [ 1400, 1000 ], [ 1400, 500 ] ].each do |w, h|
|
|
156
|
+
driver.manage.window.resize_to(w, h)
|
|
157
|
+
sidebar = driver.execute_script(<<~JS)
|
|
158
|
+
const el = document.getElementById('chrome-sidebar');
|
|
159
|
+
const b = el.getBoundingClientRect();
|
|
160
|
+
return { l: b.left, t: b.top, w: b.width, h: b.height,
|
|
161
|
+
qnt: parseFloat(getComputedStyle(el).getPropertyValue('--qnt')),
|
|
162
|
+
vh: window.innerHeight };
|
|
163
|
+
JS
|
|
164
|
+
ok = near.(sidebar["l"], 0) && near.(sidebar["t"], 0) && near.(sidebar["h"], sidebar["vh"]) && near.(sidebar["w"], 64 * sidebar["qnt"])
|
|
165
|
+
total += 1
|
|
166
|
+
failures += 1 unless ok
|
|
167
|
+
puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", "docked chrome stays pinned full-height at viewport #{h}px",
|
|
168
|
+
"l=#{sidebar['l'].round(1)} t=#{sidebar['t'].round(1)} h=#{sidebar['h'].round(1)} w=#{sidebar['w'].round(1)} (qnt #{sidebar['qnt']})")
|
|
169
|
+
end
|
|
170
|
+
|
|
148
171
|
# The unit: one thousandth of the canvas width, from CSS alone, measured against the canvas even inside a nearer
|
|
149
172
|
# size container; outside a canvas it approximates from the viewport.
|
|
150
173
|
qnt = ->(id) { driver.execute_script("return getComputedStyle(arguments[0] ? document.getElementById(arguments[0]) : document.documentElement).getPropertyValue('--qnt').trim()", id) }
|
|
@@ -171,6 +194,21 @@ Dir.mktmpdir do |dir|
|
|
|
171
194
|
failures += 1 unless ok
|
|
172
195
|
puts format("%-4s %-56s %s", ok ? "ok" : "FAIL", "text-size-linear[3]/[96] scale linearly at #{vw}px", sizes.map { |v| v.round(3) }.inspect)
|
|
173
196
|
end
|
|
197
|
+
# Clamped percentage-of-parent spacing (DESIGN_HISTORY #17.2): qss-elastic-y-min[20]/pct[100]/max[80] on a
|
|
198
|
+
# 400x300 actor's parent, canvas at 1000px (qnt=1px) so min=20px/max=80px exactly; pct[100] is 10% of the
|
|
199
|
+
# parent's actual height. Resizing the parent alone (not the canvas) proves the clamp tracks the immediate
|
|
200
|
+
# containing block's real size, floors below it, and caps above it -- not just that the middle value computes.
|
|
201
|
+
# Reset the canvas width first: the qnt sweep above leaves it at 5000px (qnt dampened to 3.2px).
|
|
202
|
+
driver.execute_script("document.getElementById('qss-canvas').style.width = '1000px'")
|
|
203
|
+
[ [ 100, 20 ], [ 300, 30 ], [ 1000, 80 ] ].each do |parent_h, expected_top|
|
|
204
|
+
driver.execute_script("document.getElementById('elastic-parent').style.height = arguments[0] + 'px'", parent_h)
|
|
205
|
+
actual = driver.execute_script("return parseFloat(getComputedStyle(document.getElementById('elastic-actor')).top)")
|
|
206
|
+
ok = near.(actual, expected_top)
|
|
207
|
+
total += 1
|
|
208
|
+
failures += 1 unless ok
|
|
209
|
+
puts format("%-4s %-56s top=%s%s", ok ? "ok" : "FAIL", "elastic-y clamp at parent height #{parent_h}px", actual, ok ? "" : " (expected #{expected_top})")
|
|
210
|
+
end
|
|
211
|
+
|
|
174
212
|
viewport = driver.execute_script("return window.innerWidth")
|
|
175
213
|
expected = "#{(viewport * 0.001).round(4).to_s.sub(/\.0\z/, '')}px"
|
|
176
214
|
actual = qnt.(nil)
|
data/exe/qss-engine-render-check
CHANGED
|
@@ -29,7 +29,10 @@ HTML = <<~HTML
|
|
|
29
29
|
<div class="foreign"><div id="foreign-fixed" style="position: fixed"></div></div>
|
|
30
30
|
</div>
|
|
31
31
|
<div id="body-fixed" style="position: fixed"></div>
|
|
32
|
-
<div id="qss-portal"
|
|
32
|
+
<div id="qss-portal">
|
|
33
|
+
<div id="portal-fixed" style="position: fixed"></div>
|
|
34
|
+
<div id="portal-auto-name" class="qss qss-w[200] qss-h[100]"></div>
|
|
35
|
+
</div>
|
|
33
36
|
<dialog id="top-layer-modal"></dialog>
|
|
34
37
|
</body></html>
|
|
35
38
|
HTML
|
|
@@ -69,7 +72,10 @@ Dir.mktmpdir do |dir|
|
|
|
69
72
|
|
|
70
73
|
[
|
|
71
74
|
[ "the engine names an actor's container after its own id", "auto-name", "auto-name" ],
|
|
72
|
-
[ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ]
|
|
75
|
+
[ "the engine never overwrites a container-name already set by anything", "explicit-name", "custom-name" ],
|
|
76
|
+
# DESIGN_HISTORY #17.3: the portal is where an entire layout's viewport-anchored chrome lives, not a
|
|
77
|
+
# secondary concern -- its actors need the same named container-query support canvas frames get.
|
|
78
|
+
[ "the engine also names an actor's container inside the portal root", "portal-auto-name", "portal-auto-name" ]
|
|
73
79
|
].each do |label, id, expected|
|
|
74
80
|
actual = computed_name.(id)
|
|
75
81
|
ok = actual == expected
|
data/lib/qss/qss_auditor.rb
CHANGED
|
@@ -33,6 +33,10 @@ module QSS
|
|
|
33
33
|
'qss-cursor-pointer',
|
|
34
34
|
'qss-cursor-row-resize',
|
|
35
35
|
'qss-dev-border',
|
|
36
|
+
'qss-elastic-y',
|
|
37
|
+
'qss-elastic-y-max',
|
|
38
|
+
'qss-elastic-y-min',
|
|
39
|
+
'qss-elastic-y-pct',
|
|
36
40
|
'qss-ergonomic-override',
|
|
37
41
|
'qss-evenly',
|
|
38
42
|
'qss-fixed',
|
|
@@ -114,7 +118,7 @@ module QSS
|
|
|
114
118
|
|
|
115
119
|
PAYLOAD_TAGS = ["p", "h1", "h2", "h3", "h4", "h5", "h6", "span", "label", "blockquote", "li"].freeze
|
|
116
120
|
|
|
117
|
-
ADJECTIVE_CATEGORIES = {"positional"=>["qss-absolute", "qss-fixed", "qss-sticky", "qss-pos-x", "qss-pos-y", "qss-pos-r", "qss-pos-b", "qss-pos"], "flow_relative"=>["qss-relative"], "layout"=>["qss-flex-col", "qss-flex-row", "qss-centered", "qss-v-start", "qss-items-center", "qss-v-end", "qss-h-start", "qss-justify-center", "qss-h-end", "qss-justify-between", "qss-around", "qss-evenly", "qss-flex", "qss-flex-wrap", "qss-flex-nowrap", "qss-flex-1", "qss-flex-auto", "qss-flex-none", "qss-grid", "qss-table-fixed", "qss-contain-layout", "qss-w-full", "qss-h-full", "qss-w-f", "qss-h-f"], "typographic"=>["qss-italic", "qss-uppercase", "qss-lowercase", "qss-underline", "qss-nowrap", "qss-truncate", "qss-font-mono", "qss-font-sans", "qss-font-medium", "qss-font-bold", "qss-font-black", "qss-text-size", "qss-text-size-linear", "qss-leading", "qss-tracking", "qss-tracking-tighter", "qss-tracking-wide", "qss-tracking-wider", "qss-tracking-widest", "qss-icon-size"], "visual"=>["qss-border", "qss-border-t", "qss-border-b", "qss-border-l", "qss-border-r", "qss-rounded", "qss-rounded-full", "qss-shadow", "qss-opacity", "qss-blur", "qss-bright", "qss-contrast", "qss-grayscale", "qss-hue", "qss-invert", "qss-saturate", "qss-sepia", "qss-blend", "qss-contain", "qss-cover", "qss-visible", "qss-invisible", "qss-bg-alpha", "qss-border-alpha", "qss-text-alpha"], "interactive"=>["qss-cursor-pointer", "qss-transition-all", "qss-busy", "qss-ghost", "qss-unselectable", "qss-scroll-smooth", "qss-scroll", "qss-cursor-col-resize", "qss-cursor-row-resize", "qss-transition-colors", "qss-transition-none", "qss-ring", "qss-resize", "qss-outline-none"], "internal"=>["qss", "qss-dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-paint-override", "qss-content-driven", "qss-overlay-fallback"]}.freeze
|
|
121
|
+
ADJECTIVE_CATEGORIES = {"positional"=>["qss-absolute", "qss-fixed", "qss-sticky", "qss-pos-x", "qss-pos-y", "qss-pos-r", "qss-pos-b", "qss-pos", "qss-elastic-y-min", "qss-elastic-y-pct", "qss-elastic-y-max", "qss-elastic-y"], "flow_relative"=>["qss-relative"], "layout"=>["qss-flex-col", "qss-flex-row", "qss-centered", "qss-v-start", "qss-items-center", "qss-v-end", "qss-h-start", "qss-justify-center", "qss-h-end", "qss-justify-between", "qss-around", "qss-evenly", "qss-flex", "qss-flex-wrap", "qss-flex-nowrap", "qss-flex-1", "qss-flex-auto", "qss-flex-none", "qss-grid", "qss-table-fixed", "qss-contain-layout", "qss-w-full", "qss-h-full", "qss-w-f", "qss-h-f"], "typographic"=>["qss-italic", "qss-uppercase", "qss-lowercase", "qss-underline", "qss-nowrap", "qss-truncate", "qss-font-mono", "qss-font-sans", "qss-font-medium", "qss-font-bold", "qss-font-black", "qss-text-size", "qss-text-size-linear", "qss-leading", "qss-tracking", "qss-tracking-tighter", "qss-tracking-wide", "qss-tracking-wider", "qss-tracking-widest", "qss-icon-size"], "visual"=>["qss-border", "qss-border-t", "qss-border-b", "qss-border-l", "qss-border-r", "qss-rounded", "qss-rounded-full", "qss-shadow", "qss-opacity", "qss-blur", "qss-bright", "qss-contrast", "qss-grayscale", "qss-hue", "qss-invert", "qss-saturate", "qss-sepia", "qss-blend", "qss-contain", "qss-cover", "qss-visible", "qss-invisible", "qss-bg-alpha", "qss-border-alpha", "qss-text-alpha"], "interactive"=>["qss-cursor-pointer", "qss-transition-all", "qss-busy", "qss-ghost", "qss-unselectable", "qss-scroll-smooth", "qss-scroll", "qss-cursor-col-resize", "qss-cursor-row-resize", "qss-transition-colors", "qss-transition-none", "qss-ring", "qss-resize", "qss-outline-none"], "internal"=>["qss", "qss-dev-border", "group", "foreign", "qss-legibility-override", "qss-ergonomic-override", "qss-payload-override", "qss-paint-override", "qss-content-driven", "qss-overlay-fallback"]}.freeze
|
|
118
122
|
|
|
119
123
|
ILLEGAL_COMBOS = [{"tag_in"=>"payload_tags", "forbid_category"=>"positional", "severity"=>"red", "message"=>"Protocol Violation: Payload tags (<tag>) cannot carry flow-breaking positional adjectives. Use a wrapping <div> Frame.", "escape_hatch"=>"qss-payload-override"}].freeze
|
|
120
124
|
|
data/lib/qss/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: qss
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kraig Fairhurst
|
|
@@ -94,13 +94,15 @@ dependencies:
|
|
|
94
94
|
- !ruby/object:Gem::Version
|
|
95
95
|
version: '0'
|
|
96
96
|
description: |-
|
|
97
|
-
AI coding
|
|
97
|
+
Ask an AI coding agent to move a button 10 pixels left, and watch it struggle. CSS gives it five or six different ways to control position: margin, padding, a flex order, a grid column, a transform. Nothing says which one is actually in charge on this particular page, so the agent guesses, edits the wrong one, and something else quietly shifts. It reports the change as done. The page is now broken.
|
|
98
98
|
|
|
99
|
-
QSS
|
|
99
|
+
QSS removes the guessing by removing the choice. Every element's position and size is one explicit number, not a tug-of-war between properties. Those numbers are written in a single unit, a "qnt" (1/1000th of the layout's own width), so the same edit holds its exact proportion at any screen size -- a phone, a laptop, a 4K display -- with no breakpoints, no media queries, and no separate rules to keep in sync. Move something once and it stays put, everywhere.
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
That is the whole idea, and it helps humans exactly as much as it helps agents: fewer rules to maintain, one obvious place to make a change, and layouts that stay consistent across devices by construction instead of by discipline.
|
|
102
102
|
|
|
103
|
-
QSS
|
|
103
|
+
QSS backs that up with two automatic auditors that catch mistakes a human reviewer might miss (a RED finding is a broken rule, ORANGE is a judgment call) and a real-browser render check, because a passing lint rule is not proof a page actually displays correctly -- only watching it render is. It ships compiled, ready-to-use CSS plus its SCSS source, works with zero JavaScript, and offers an optional JS engine for a few conveniences on top. It stays out of the way of whatever you use for colors, type and visual polish (Tailwind or anything else) -- QSS only ever owns position, size and structure.
|
|
104
|
+
|
|
105
|
+
QSS was extracted from a real Rails development tool that still runs on it today, not built as a theoretical exercise. See docs/HOWTO.md to get started and docs/DESIGN_HISTORY.md for the full story of what was tried, what broke, and why each decision landed where it did.
|
|
104
106
|
|
|
105
107
|
Status: 0.x. Expect names and APIs to change before 1.0.
|
|
106
108
|
email:
|
|
@@ -125,6 +127,8 @@ files:
|
|
|
125
127
|
- config/grammar.yml
|
|
126
128
|
- config/integrity.json
|
|
127
129
|
- config/qss-objects.json
|
|
130
|
+
- docs/DESIGN_HISTORY.md
|
|
131
|
+
- docs/HOWTO.md
|
|
128
132
|
- exe/qss-core-render-check
|
|
129
133
|
- exe/qss-engine-render-check
|
|
130
134
|
- exe/qss-seal
|
|
@@ -144,7 +148,7 @@ metadata:
|
|
|
144
148
|
source_code_uri: https://bitbucket.org/kmf2/qss
|
|
145
149
|
bug_tracker_uri: https://bitbucket.org/kmf2/qss/issues
|
|
146
150
|
changelog_uri: https://bitbucket.org/kmf2/qss/src/main/CHANGELOG.md
|
|
147
|
-
documentation_uri: https://bitbucket.org/kmf2/qss/src/main/docs/
|
|
151
|
+
documentation_uri: https://bitbucket.org/kmf2/qss/src/main/docs/HOWTO.md
|
|
148
152
|
allowed_push_host: https://rubygems.org
|
|
149
153
|
rubygems_mfa_required: 'true'
|
|
150
154
|
rdoc_options: []
|
|
@@ -163,6 +167,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
163
167
|
requirements: []
|
|
164
168
|
rubygems_version: 4.0.13
|
|
165
169
|
specification_version: 4
|
|
166
|
-
summary:
|
|
167
|
-
|
|
170
|
+
summary: A CSS layout system built so AI agents (and the humans reviewing them) never
|
|
171
|
+
have to guess
|
|
168
172
|
test_files: []
|