trykeyline 0.0.0-stage → 0.1.1
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.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Long-form prose
|
|
2
|
+
|
|
3
|
+
How Glass sets the reading half: looser leading and spacing than anything else on the site, inside the *same* 640px measure, at the *same* two sizes. The blocks, their axes and the rules they keep are in `domains/type/prose-and-emphasis.md`; this file holds Glass's values and code.
|
|
4
|
+
|
|
5
|
+
## The container sets the register
|
|
6
|
+
|
|
7
|
+
```tsx
|
|
8
|
+
<div className="text-[14px] leading-[1.75] text-gray-1000">
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Prose body is 14/1.75: a UI paragraph's size, noticeably more open (1.75 vs 1.55).
|
|
12
|
+
|
|
13
|
+
Why 1.75: a 576px text column at 14px holds roughly 85–95 characters a line, past comfortable, and long lines need more leading for the eye to find the next line's start. A shorter measure could pull it back toward 1.6.
|
|
14
|
+
|
|
15
|
+
## The reading column is two sizes
|
|
16
|
+
|
|
17
|
+
16 for headings, 14 for everything a reader reads. The blocks sharing 14px each own a different signal (domain law 4):
|
|
18
|
+
|
|
19
|
+
| Block | Size | The signal |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| Lead | 14 | leading at 1.9, and 32px below |
|
|
22
|
+
| Keyline | 14 | weight 500, and 36px of air on both sides |
|
|
23
|
+
| Body | 14 | leading at 1.75 — the baseline the others are read against |
|
|
24
|
+
| Pull quote | 14 | a 3px rounded bar and a 16px indent |
|
|
25
|
+
| Note | 13 | a 1px rule, the faint tier, and a step down in size |
|
|
26
|
+
|
|
27
|
+
## Keyline and note
|
|
28
|
+
|
|
29
|
+
Both came from one diagnosis: a body of writing that was 114 paragraphs and almost nothing else. What each is for, and the build check that keeps them honest: `domains/type/prose-and-emphasis.md`, **Two block types worth having**.
|
|
30
|
+
|
|
31
|
+
**`keyline`** — body size, weight 500, `my-9`.
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
case "keyline":
|
|
35
|
+
return <p className="reveal my-9 text-[14px] font-medium leading-[1.6] text-gray-1000">…</p>;
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**`note`** — 13px, faint tier, a 1px left rule and 16px of indent.
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
case "note":
|
|
42
|
+
return <p className="reveal my-7 border-l border-gray-alpha-300 pl-4 text-[13px] leading-[1.6] text-faint">…</p>;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The note's rule is 1px and the quote's 3px, with the type inside four steps apart, so the two indented rules can't be mistaken for each other.
|
|
46
|
+
|
|
47
|
+
## Block spacing
|
|
48
|
+
|
|
49
|
+
| Block | Spacing | Note |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| Paragraph | `mb-6` (24px) | ~1.7× the type size. |
|
|
52
|
+
| Lead | `mb-8` (32px) | More air below, to set the opening apart. |
|
|
53
|
+
| Heading (h2) | `mt-10 mb-3` (40 / 12) | Asymmetric: a heading belongs to what follows it. |
|
|
54
|
+
| Keyline | `my-9` (36px) | Symmetric — it belongs to neither neighbour, which is the point. |
|
|
55
|
+
| Quote | `my-9` (36px) | More air than a paragraph, both sides. |
|
|
56
|
+
| Note | `my-7` (28px) | Stepped in from the quote so the two read differently. |
|
|
57
|
+
| Figure | `my-9` (36px) | |
|
|
58
|
+
| List | `mb-6`, items `space-y-2` | Matches paragraph spacing so a list reads as one block. |
|
|
59
|
+
|
|
60
|
+
The heading's 40 above and 12 below, ~3.3:1, is the domain's asymmetric heading space; the keyline is its symmetric exception.
|
|
61
|
+
|
|
62
|
+
## Headings inside prose
|
|
63
|
+
|
|
64
|
+
```tsx
|
|
65
|
+
<h2 className="mt-10 mb-3 text-[16px] font-medium leading-[1.2] tracking-[-0.015em]">
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
16px against 14px body is one step; the weight (500), the tight leading (1.2) and the 40px above do the real work.
|
|
69
|
+
|
|
70
|
+
It matches a page-level section heading and the detail page's own `<h1>`: three levels at one size, separated by position and spacing. Flatter than most article templates, and deliberate — the alternative was an in-body heading larger than the page title.
|
|
71
|
+
|
|
72
|
+
## Quotes
|
|
73
|
+
|
|
74
|
+
```tsx
|
|
75
|
+
<blockquote className="my-9 flex gap-4">
|
|
76
|
+
<span aria-hidden className="w-[3px] shrink-0 rounded-full bg-gray-500" />
|
|
77
|
+
<div className="text-[14px] leading-[1.75] text-gray-1000">…</div>
|
|
78
|
+
</blockquote>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The type matches the body around it, so the bar is the entire signal: the 2px `gray-alpha-300` border it replaced is domain law 4's example of auditing what's left after a signal goes. Why a flex child with no vertical margin: `domains/type/prose-and-emphasis.md`, **Quotes**.
|
|
82
|
+
|
|
83
|
+
- **`bg-gray-500`, from the grey ramp, not the alpha ramp.** It's a decorative mark on an opaque surface — see the Glass colour guide (`glass/color/guide.md`) on why decorative marks stay on the solid ramp.
|
|
84
|
+
|
|
85
|
+
Still absent: italic (globally disabled), quotation marks, background, colour step. Stepping the quote *down* to secondary, as earlier versions did, at body size just makes a de-emphasised paragraph.
|
|
86
|
+
|
|
87
|
+
The citation goes quieter and explicitly kills the browser's italic (domain law 9):
|
|
88
|
+
|
|
89
|
+
```tsx
|
|
90
|
+
<cite className="mt-2 block text-[13px] not-italic text-quiet">— {cite}</cite>
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Figures
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
<figure className="reveal my-9">
|
|
97
|
+
<div className="overflow-hidden rounded-[10px] bg-gray-100 ring-1 ring-gray-alpha-300">
|
|
98
|
+
<div style={{ aspectRatio: b.aspect ?? "16 / 9" }}>
|
|
99
|
+
<img src={b.src} alt={b.alt} loading="lazy" decoding="async" className="h-full w-full object-cover" />
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<figcaption className="mt-2.5 text-[13px] leading-[1.55] text-faint">{caption}</figcaption>
|
|
103
|
+
</figure>
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The aspect ratio sits on a wrapper so the box is reserved before the image loads. `ring-1` rather than a border, so the outline stays out of the box model and the corner radius stays exact.
|
|
107
|
+
|
|
108
|
+
**The caption is the point of the figure, not a label on it**: what the thing was for, and which constraint shaped it. It sits at the note/meta tier (13px, faint).
|
|
109
|
+
|
|
110
|
+
## Lists
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
<ul className="mb-6 list-disc space-y-2 pl-5 marker:text-gray-500">
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The `marker:` variant puts the domain's quiet marker (`domains/type/prose-and-emphasis.md`, **Lists**) on `gray-500`; the same goes for `list-decimal`.
|
|
117
|
+
|
|
118
|
+
## Hand-rolled bullets in UI context
|
|
119
|
+
|
|
120
|
+
Outside prose, as in a role's responsibilities list, a custom marker gives finer control:
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
<li className="flex gap-2.5 text-[14px] leading-[1.65]">
|
|
124
|
+
<span aria-hidden className="mt-[9px] h-[3px] w-[3px] shrink-0 rounded-full bg-gray-500" />
|
|
125
|
+
<span>{text}</span>
|
|
126
|
+
</li>
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
A 3px dot at the quiet tier, offset `mt-[9px]` onto the first line's optical centre. The 9px is derived: 14px × 1.65 leading = 23.1px line box, centre at ~11.5px; minus half the 3px dot = 10px; minus a hair because a dot reads slightly low against lowercase x-height = 9px.
|
|
130
|
+
|
|
131
|
+
`aria-hidden` matters — a decorative span inside a real `<li>` would otherwise be announced.
|
|
132
|
+
|
|
133
|
+
## Emphasis in prose
|
|
134
|
+
|
|
135
|
+
The mapping is §5 of `glass/type/guide.md`. **`<strong>` and the keyline are the same weight at the same size**, so a bolded clause can read as a keyline that failed to get its own line; keep it to a few words (domain law 9).
|
|
136
|
+
|
|
137
|
+
## Reveal on scroll
|
|
138
|
+
|
|
139
|
+
Every block carries `reveal`, so paragraphs fade up one by one as they enter the viewport instead of the article arriving as one slab. See the Glass motion guide (`glass/motion/guide.md`) for the implementation and its reduced-motion behaviour.
|
|
140
|
+
|
|
141
|
+
## The dek
|
|
142
|
+
|
|
143
|
+
```tsx
|
|
144
|
+
<p className="mt-2 text-[14px] leading-[1.55] text-muted">{dek}</p>
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Body size, muted, tighter leading. It was 17px, the ladder's one odd number, sitting between a 30px title and 16px prose; with both gone it separates on colour and leading alone: 1.55, against the body's 1.75 and the lead's 1.9. Dek and lead are the domain's tightest pairing; check them first.
|
|
148
|
+
|
|
149
|
+
## Cover art and the measure
|
|
150
|
+
|
|
151
|
+
The cover image stays in the text's 640px column, not bled full-width, so the page reads as one block from title to last paragraph and the image belongs to the article rather than bannering it. A `16 / 7` default aspect ratio is reserved before load so nothing shifts — in-body figures default to `16 / 9`, and both take an explicit ratio when the source has one.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# The scale, derived
|
|
2
|
+
|
|
3
|
+
Why the ladder in `glass/type/guide.md` looks the way it does, and how to extend it without wrecking it. The case against modular scales, and the general method for adding and cutting roles, are in `domains/type/ladders.md`.
|
|
4
|
+
|
|
5
|
+
## Role-first
|
|
6
|
+
|
|
7
|
+
A modular scale gives too few sizes in the 11–16px band, where Glass needs 11, 12, 13, 14 and 16 — tooltip, tag, timestamp, nav row and paragraph are five jobs at five densities — and hands out 22, 27 and 34 above an 18px ceiling. Every size here exists because a specific piece of the interface needs it. Grouped by where they appear:
|
|
8
|
+
|
|
9
|
+
**Content — one measure, top to bottom**
|
|
10
|
+
- 18px index-page title, the largest role: `/writing`, `/work`, `/experience`, `/resume`, `/skills`, a skill's own page. It labels a collection, and it's the only place tracking goes to −0.02em.
|
|
11
|
+
- 16px everything structural — a detail page's title, a section heading, a prose heading. Three roles, separated by leading (1.3 for a wrapping title, 1.2 for a heading) and position.
|
|
12
|
+
- 14px everything read — body, lead, keyline, pull quote, dek, sub-headings. Six roles at one size, where the system does its real work; §1 of `glass/type/guide.md` tabulates how they stay apart.
|
|
13
|
+
- 13.5px summary lines. The half-pixel is deliberate: a collapsed summary is read once, deciding whether to continue — a hair above the note tier, below the body it summarises. Use half-steps at most once.
|
|
14
|
+
- 13px the apparatus — notes, captions, citations, facts, meta, back links.
|
|
15
|
+
|
|
16
|
+
**Chrome — dense, scannable**
|
|
17
|
+
- 12.5px code. Monospace runs wide, so it steps down to occupy the same measure as the prose around it — the one size that compensates for a *family* rather than a role.
|
|
18
|
+
- 12px. Tags, prev/next labels, hover-card titles; small enough to read as annotation.
|
|
19
|
+
- 11px. Tooltips and avatar initials. The floor for anything a user is expected to read.
|
|
20
|
+
- 8.5px lettermarks, 7.5px card lettering. Below the floor on purpose — recognised rather than read, which is also why 7.5px is the one place a 600 weight is allowed.
|
|
21
|
+
|
|
22
|
+
**What was deleted**
|
|
23
|
+
|
|
24
|
+
An earlier ladder had 30, 19, 17 and 15. All four are gone, cut from the top down (domain law 1):
|
|
25
|
+
|
|
26
|
+
| Size | Was | Why it went |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| 30 | Article title | Two templates opening two steps above every other page. Now 16. |
|
|
29
|
+
| 19 | Prose heading, pull quote | Came down with the title — an in-body heading can't outrank the page's own. Now 16 and 14. |
|
|
30
|
+
| 17 | Article dek | Existed only to sit between 30 and 16; with both gone, no gap to fill. Now 14, muted. |
|
|
31
|
+
| 15 | `<body>` base | A default nothing used explicitly — a size waiting to be used by accident. |
|
|
32
|
+
|
|
33
|
+
## Adding a role
|
|
34
|
+
|
|
35
|
+
Ask the three questions in `domains/type/ladders.md`. Here the first — can an existing size take it with different leading or weight? — is the answer 80% of the time: 14px carries four roles this way.
|
|
36
|
+
|
|
37
|
+
## The 1–2px step
|
|
38
|
+
|
|
39
|
+
In the 11–16px band the steps are 1px, and that is enough (`domains/type/ladders.md`, **Small steps are not small**): a 13px meta line and a 14px paragraph are told apart mostly by faint against primary, and position. Above 16px there is one step, 18, where the ladder stops — the system's most consequential choice; §1 of `glass/type/guide.md` argues it.
|
|
40
|
+
|
|
41
|
+
## Responsive behaviour
|
|
42
|
+
|
|
43
|
+
**Nothing resizes.** The 30px article title used to drop to 26px below `sm`, because in a ~311px phone column it wrapped to three lines; at an 18px ceiling the rule went with the size. Why fixing the ladder beats tuning a breakpoint, and why sizes hold on a phone: `domains/type/ladders.md`, **Viewports**.
|
|
44
|
+
|
|
45
|
+
## Fixed sizes, not `rem`, and why that's a choice
|
|
46
|
+
|
|
47
|
+
The reference implementation uses `px` throughout (`text-[14px]`, not `text-sm` or `0.875rem`).
|
|
48
|
+
|
|
49
|
+
The trade: `rem` respects a user's browser font-size setting and `px` doesn't, a real accessibility cost. But this hierarchy depends on precise 1px relationships in the 11–16 band, a root scaled to 20px turns them into 15px steps that break the ladder, and browser *zoom* — which everyone uses, and which scales `px` fine — already covers the need.
|
|
50
|
+
|
|
51
|
+
If your audience skews toward users who set a root font size — documentation, government, healthcare — use `rem` and accept a looser ladder. Convert at a fixed 16px root: 18px → 1.125rem, 14px → 0.875rem, 13px → 0.8125rem. Never mix the two (the domain's unit dial).
|
|
52
|
+
|
|
53
|
+
## Keeping every element on it
|
|
54
|
+
|
|
55
|
+
Every type change ends in the sweep in `domains/type/type-audit.md`: every route, raw sizes moved onto role tokens, the same element checked across pages. Glass's role names, registered with `tailwind-merge` so `cn()` doesn't file `text-copy` as a colour and drop it:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { extendTailwindMerge } from "tailwind-merge";
|
|
59
|
+
|
|
60
|
+
export const twMerge = extendTailwindMerge({
|
|
61
|
+
extend: {
|
|
62
|
+
classGroups: {
|
|
63
|
+
"font-size": [{ text: ["page", "section", "copy", "summary", "dense", "code", "micro", "tip", "mark", "card"] }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Then check the *rendered* size of one element per role, not the source: the class can be written there and never reach the markup.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Weight and tracking
|
|
2
|
+
|
|
3
|
+
Glass's weights and tracking values, with the decisions behind them. Why two weights, why weight is a state signal and why tracking runs negative with size: domain laws 5 and 7 in `domains/type/guide.md`.
|
|
4
|
+
|
|
5
|
+
## Weight
|
|
6
|
+
|
|
7
|
+
### Two weights
|
|
8
|
+
|
|
9
|
+
400 and 500: a 100-unit gap, the smallest step most families offer, and enough. Keep the gap at 100 and the axis stays free for active nav rows, sub-headings, emphasis and lettermarks.
|
|
10
|
+
|
|
11
|
+
### Where each lands
|
|
12
|
+
|
|
13
|
+
**400 — the default.**
|
|
14
|
+
All body copy. All prose. Deks. Meta lines. Tags. Tooltips. Timestamps. Quiet nav rows. Pull quotes (already set apart by size, leading and a border; weight makes them strident).
|
|
15
|
+
|
|
16
|
+
**500 — structure and state.**
|
|
17
|
+
Headings h1–h4. `<strong>`. The active row in a table of contents. Names and wordmarks. Captions that label an image rather than describe it. Lettermarks. Company/role titles in a list, where the role is structural and the date supporting.
|
|
18
|
+
|
|
19
|
+
### Quiet and lifted, by scenario
|
|
20
|
+
|
|
21
|
+
The lists above are defaults; the choice is made per spot, by the rules in domain law 5. The same 13px line is 400 as a date under a title and 500 as the name heading a group, because the job changed, not the importance. With two weights, a step is the whole axis, so when a screen feels heavy the fix is moving lines back to 400, one context at a time; there's never a lighter scale to reach for.
|
|
22
|
+
|
|
23
|
+
### When 300 is legitimate
|
|
24
|
+
|
|
25
|
+
Rarely, and never for text read at length: at a very large display size (48px+), where 400 starts to look heavy, or a decorative numeral. If your interface tops out at 30px — most do — you don't need it. The reference implementation loaded 300 for a while and never used it; dropping it from the font request was a straight performance win.
|
|
26
|
+
|
|
27
|
+
### Weight as an accessibility signal
|
|
28
|
+
|
|
29
|
+
The active table-of-contents row takes the primary text colour *and* weight 500 (domain law 5):
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
className={`… ${tone} ${state === "active" ? "font-medium" : ""}`}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Weight is cheaper here than a background pill, border or icon, each of which adds visual weight to a list meant to be quiet.
|
|
36
|
+
|
|
37
|
+
## Tracking
|
|
38
|
+
|
|
39
|
+
### The ladder
|
|
40
|
+
|
|
41
|
+
| Size | Tracking | Reasoning |
|
|
42
|
+
|---|---|---|
|
|
43
|
+
| 7.5px | −0.01em | Card lettering, set solid. The exception below the floor — see the note. |
|
|
44
|
+
| 8.5–11px | +0.02em | Tiny glyphs and near-caps need air or the counters close up. |
|
|
45
|
+
| 12–14px | 0 | Default metrics are designed for this range. Leave them. |
|
|
46
|
+
| 16px | −0.015em | Enough to look set; small enough to be invisible as a decision. |
|
|
47
|
+
| 18px | −0.02em | A page title is a tighter, more deliberate object than a section heading two pixels below. |
|
|
48
|
+
|
|
49
|
+
Only two rows are the negative-tracking-at-size rule, because the ladder tops out at 18px. An earlier table ran to 30px at −0.025em, with separate values for a 19px heading and a 19px quote; all three sizes left the ladder and took their tracking with them.
|
|
50
|
+
|
|
51
|
+
The 7.5px row doesn't contradict the 8.5–11px one: that lettering is set solid at near-caps size on a card, so the tightening compensates for the *setting*, not the size. It's also the one place a 600 weight is allowed. Two exceptions in one place hint you're outside the text system — which you are, since nobody reads it.
|
|
52
|
+
|
|
53
|
+
### The 8.5px lettermark
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
<span className="… border border-gray-alpha-400 px-[3px] text-[8.5px] font-medium tracking-[0.02em] text-gray-800">
|
|
57
|
+
{letters}
|
|
58
|
+
</span>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Not text but a mark: a two-or-three-letter stand-in for a logo, in the same 20px optical box as the real SVG logos beside it. It's below the readability floor on purpose — you recognise "TS" the way you recognise an icon (domain law 1). The `+0.02em` and the `px-[3px]` both keep the letters off the border box.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A forgiving CSS reader: enough structure for lint rules, no dependencies.
|
|
3
|
+
*
|
|
4
|
+
* It returns every declaration with its line, the selector it sits under and
|
|
5
|
+
* the at-rules around it, plus every at-rule prelude. It doesn't validate and
|
|
6
|
+
* it doesn't resolve var(); nested rules (SCSS, CSS nesting) keep their
|
|
7
|
+
* innermost selector. Comments are blanked rather than removed, so offsets and
|
|
8
|
+
* line numbers stay true to the source.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Replace comments with spaces, keeping newlines, so positions survive. */
|
|
12
|
+
export function blankComments(text) {
|
|
13
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function lineAt(text, offset) {
|
|
17
|
+
let line = 1;
|
|
18
|
+
for (let i = 0; i < offset && i < text.length; i++) if (text.charCodeAt(i) === 10) line++;
|
|
19
|
+
return line;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Lines start offsets, for fast offset → line lookups on big files. */
|
|
23
|
+
export function lineIndex(text) {
|
|
24
|
+
const starts = [0];
|
|
25
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) starts.push(i + 1);
|
|
26
|
+
return (offset) => {
|
|
27
|
+
let lo = 0;
|
|
28
|
+
let hi = starts.length - 1;
|
|
29
|
+
while (lo < hi) {
|
|
30
|
+
const mid = (lo + hi + 1) >> 1;
|
|
31
|
+
if (starts[mid] <= offset) lo = mid;
|
|
32
|
+
else hi = mid - 1;
|
|
33
|
+
}
|
|
34
|
+
return lo + 1;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} source CSS text
|
|
40
|
+
* @param {number} baseLine line number of the first character (for <style> blocks)
|
|
41
|
+
*/
|
|
42
|
+
export function parseCss(source, baseLine = 1) {
|
|
43
|
+
const text = blankComments(source);
|
|
44
|
+
const at = lineIndex(text);
|
|
45
|
+
const decls = [];
|
|
46
|
+
const atRules = [];
|
|
47
|
+
const blocks = []; // { selector, atRules, decls }
|
|
48
|
+
const stack = []; // open blocks: { kind: 'rule'|'at', prelude, block }
|
|
49
|
+
let start = 0;
|
|
50
|
+
let quote = null;
|
|
51
|
+
let paren = 0;
|
|
52
|
+
|
|
53
|
+
const context = () => ({
|
|
54
|
+
selector: [...stack].reverse().find((s) => s.kind === "rule")?.prelude ?? "",
|
|
55
|
+
atRules: stack.filter((s) => s.kind === "at").map((s) => s.prelude),
|
|
56
|
+
block: stack.at(-1)?.block ?? null,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const pushDecl = (from, to) => {
|
|
60
|
+
const raw = text.slice(from, to);
|
|
61
|
+
const colon = raw.indexOf(":");
|
|
62
|
+
if (colon < 1) return;
|
|
63
|
+
const prop = raw.slice(0, colon).trim().toLowerCase();
|
|
64
|
+
if (!/^(--)?[a-z-]+$/.test(prop) && !/^--[\w-]+$/.test(prop)) return;
|
|
65
|
+
const value = raw.slice(colon + 1).trim();
|
|
66
|
+
const lead = from + raw.search(/\S/);
|
|
67
|
+
const ctx = context();
|
|
68
|
+
const d = { prop, value, line: at(lead) + baseLine - 1, offset: lead, selector: ctx.selector, atRules: ctx.atRules, block: ctx.block };
|
|
69
|
+
decls.push(d);
|
|
70
|
+
ctx.block?.decls.push(d);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < text.length; i++) {
|
|
74
|
+
const c = text[i];
|
|
75
|
+
if (quote) {
|
|
76
|
+
if (c === "\\") i++;
|
|
77
|
+
else if (c === quote) quote = null;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (c === '"' || c === "'") quote = c;
|
|
81
|
+
else if (c === "(") paren++;
|
|
82
|
+
else if (c === ")") paren = Math.max(0, paren - 1);
|
|
83
|
+
else if (paren > 0) continue;
|
|
84
|
+
else if (c === "{") {
|
|
85
|
+
const prelude = text.slice(start, i).trim().replace(/\s+/g, " ");
|
|
86
|
+
const kind = prelude.startsWith("@") ? "at" : "rule";
|
|
87
|
+
const lead = start + text.slice(start, i).search(/\S/);
|
|
88
|
+
const block = { selector: kind === "rule" ? prelude : context().selector, atRules: [...context().atRules, ...(kind === "at" ? [prelude] : [])], decls: [], line: at(Math.max(lead, 0)) + baseLine - 1 };
|
|
89
|
+
if (kind === "at") atRules.push({ prelude, line: block.line });
|
|
90
|
+
blocks.push(block);
|
|
91
|
+
stack.push({ kind, prelude, block });
|
|
92
|
+
start = i + 1;
|
|
93
|
+
} else if (c === ";") {
|
|
94
|
+
if (stack.length) pushDecl(start, i);
|
|
95
|
+
else {
|
|
96
|
+
const prelude = text.slice(start, i).trim();
|
|
97
|
+
if (prelude.startsWith("@")) atRules.push({ prelude, line: at(start + text.slice(start, i).search(/\S/)) + baseLine - 1 });
|
|
98
|
+
}
|
|
99
|
+
start = i + 1;
|
|
100
|
+
} else if (c === "}") {
|
|
101
|
+
if (stack.length) pushDecl(start, i);
|
|
102
|
+
stack.pop();
|
|
103
|
+
start = i + 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return { decls, atRules, blocks };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse a `style="..."` attribute's body. */
|
|
110
|
+
export function parseInlineStyle(body, line) {
|
|
111
|
+
return parseCss(`x{${body}}`, line).decls.map((d) => ({ ...d, selector: "[style]", inline: true }));
|
|
112
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the detector takes from DESIGN.md: the preset (which turns some rules on
|
|
3
|
+
* or off), the motion ladder and the z-index ladder. Everything else in the
|
|
4
|
+
* file is for the agent; the detector only needs what changes a verdict.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PRESETS = ["editorial", "product", "expressive", "glass"];
|
|
10
|
+
|
|
11
|
+
function section(md, heading) {
|
|
12
|
+
const m = md.match(new RegExp(`^##\\s+${heading}\\b[^\\n]*\\n([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))`, "mi"));
|
|
13
|
+
return m ? m[1] : "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readDesign(root) {
|
|
17
|
+
const name = existsSync(root) ? readdirSync(root).find((f) => f.toLowerCase() === "design.md") : null;
|
|
18
|
+
if (!name) return { found: false, preset: null, durations: null, roles: new Map(), zLadder: null };
|
|
19
|
+
const md = readFileSync(join(root, name), "utf8");
|
|
20
|
+
const headingLine = (h) => {
|
|
21
|
+
const i = md.search(new RegExp(`^##\\s+${h}\\b`, "mi"));
|
|
22
|
+
return i < 0 ? 1 : md.slice(0, i).split("\n").length;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const profile = section(md, "Profile");
|
|
26
|
+
const presetRow = profile.split("\n").find((l) => /^\|\s*Preset\s*\|/i.test(l)) ?? "";
|
|
27
|
+
const preset = PRESETS.find((p) => new RegExp(`\`${p}\`|\\b${p}\\b`).test(presetRow.split("|").slice(2).join("|"))) ?? null;
|
|
28
|
+
|
|
29
|
+
// Durations: every ms / s value in the Motion section's table rows. Rows on a
|
|
30
|
+
// canonical duration.* role (before any "Named exemptions" line) are also kept by
|
|
31
|
+
// role, so the detector can check they grow with the size of the change.
|
|
32
|
+
const motion = section(md, "Motion");
|
|
33
|
+
const durations = new Set();
|
|
34
|
+
const roles = new Map();
|
|
35
|
+
let inExemptions = false;
|
|
36
|
+
for (const line of motion.split("\n")) {
|
|
37
|
+
if (!line.startsWith("|")) {
|
|
38
|
+
if (/exemption/i.test(line)) inExemptions = true;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const m of line.matchAll(/(?<![\w.])(\d+(?:\.\d+)?)(ms|s)\b/g)) {
|
|
42
|
+
const ms = m[2] === "s" ? Math.round(parseFloat(m[1]) * 1000) : Math.round(parseFloat(m[1]));
|
|
43
|
+
durations.add(ms);
|
|
44
|
+
const role = line.split("|")[1]?.match(/duration\.(\w+)/)?.[1];
|
|
45
|
+
if (!inExemptions && role && !roles.has(role)) roles.set(role, ms);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// z-index: rows whose role is z.*, or a project role whose name is a z utility
|
|
50
|
+
// or mentions one — their values are the ladder.
|
|
51
|
+
const layout = section(md, "Layout");
|
|
52
|
+
const zLadder = new Set();
|
|
53
|
+
for (const row of layout.split("\n").filter((l) => /^\|\s*`?z\./.test(l) || (l.startsWith("|") && /`-?z-\[?-?\d|`z\./.test(l)))) {
|
|
54
|
+
const cells = row.split("|").slice(1, -1).map((c) => c.trim());
|
|
55
|
+
for (const c of cells.slice(1)) for (const m of c.matchAll(/(?<![\w.#-])(-?\d+)(?![\w.%])/g)) zLadder.add(Number(m[1]));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
found: true,
|
|
60
|
+
file: name,
|
|
61
|
+
lines: { motion: headingLine("Motion"), layout: headingLine("Layout") },
|
|
62
|
+
preset,
|
|
63
|
+
durations: durations.size ? durations : null,
|
|
64
|
+
roles,
|
|
65
|
+
zLadder: zLadder.size ? zLadder : null,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which files the detector reads. The same skips as scan.mjs, so the two never
|
|
3
|
+
* disagree about what counts as the project's interface.
|
|
4
|
+
*/
|
|
5
|
+
import { readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { extname, join, relative, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const SKIP_DIRS = new Set([
|
|
9
|
+
"node_modules", ".git", ".next", ".nuxt", ".output", ".svelte-kit", ".turbo",
|
|
10
|
+
".vercel", ".cache", "dist", "build", "out", "coverage", "vendor",
|
|
11
|
+
"storybook-static", ".claude", ".agents", "public", "static",
|
|
12
|
+
]);
|
|
13
|
+
export const STYLE = new Set([".css", ".scss", ".sass", ".less", ".pcss"]);
|
|
14
|
+
export const MARKUP = new Set([".tsx", ".jsx", ".ts", ".js", ".mjs", ".vue", ".svelte", ".astro", ".html", ".mdx"]);
|
|
15
|
+
const SKIP_FILES = /\.(min|d|test|spec|stories|config)\.[a-z]+$|^(eslint|postcss|tailwind|next|vite|vitest|jest|playwright)\.config\./;
|
|
16
|
+
|
|
17
|
+
/** Every style and markup file under `paths`, relative to `root`, minus ignores. */
|
|
18
|
+
export function collect(root, paths, ignores) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const ignored = (rel) => ignores.some((ig) => ig && (rel === ig || rel.startsWith(ig.endsWith("/") ? ig : ig + "/")));
|
|
21
|
+
const visit = (abs) => {
|
|
22
|
+
const rel = relative(root, abs).split(sep).join("/");
|
|
23
|
+
if (rel && ignored(rel)) return;
|
|
24
|
+
let st;
|
|
25
|
+
try {
|
|
26
|
+
st = statSync(abs);
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (st.isDirectory()) {
|
|
31
|
+
const name = abs.split(sep).pop();
|
|
32
|
+
if (rel && (SKIP_DIRS.has(name) || (name.startsWith(".") && name !== ".storybook"))) return;
|
|
33
|
+
for (const e of readdirSync(abs)) visit(join(abs, e));
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const ext = extname(abs);
|
|
37
|
+
if (!(STYLE.has(ext) || MARKUP.has(ext))) return;
|
|
38
|
+
if (SKIP_FILES.test(abs.split(sep).pop())) return;
|
|
39
|
+
if (st.size > 400_000) return; // generated bundles, not source
|
|
40
|
+
out.push({ abs, rel: rel || abs.split(sep).pop(), ext, kind: STYLE.has(ext) ? "style" : "markup" });
|
|
41
|
+
};
|
|
42
|
+
for (const p of paths) visit(p);
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the detector needs from markup and script files, found without a parser:
|
|
3
|
+
* class lists (attributes, class helpers, string constants), inline styles,
|
|
4
|
+
* <style> blocks, CSS-in-JS templates, and the text of buttons and links.
|
|
5
|
+
*
|
|
6
|
+
* Regexes rather than an AST so it reads any framework's files the same way
|
|
7
|
+
* and never fails on syntax it doesn't know. The cost is precision: a class
|
|
8
|
+
* built by concatenation is invisible, and a string that only looks like a
|
|
9
|
+
* class list can be read as one. Rules are written to tolerate both.
|
|
10
|
+
*/
|
|
11
|
+
import { blankComments, lineIndex, parseCss, parseInlineStyle } from "./css.mjs";
|
|
12
|
+
|
|
13
|
+
const CLASS_ATTR = /\b(?:class|className|class:list)\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\}|\{\s*`([^`]*)`\s*\})/g;
|
|
14
|
+
const CLASS_CALL = /\b(?:cn|clsx|cx|classNames|classnames|twMerge|twJoin|cva|tv)\s*\(/g;
|
|
15
|
+
const EXPR_STRING = /(?:[=:(,\[?]|\breturn|&&|\|\|)\s*(["'`])((?:(?!\1)[^\\\n]|\\.)*)\1/g;
|
|
16
|
+
const CLASSY = /^[\w\s:\-\[\]\/.#%(),!&>*@=+'"~]*$/;
|
|
17
|
+
const TAILWINDISH = /(?:^|\s)!?(?:[\w-]+:)*-?(?:p[xytrbl]?|m[xytrbl]?|text|bg|border|rounded|flex|grid|gap|w|h|min-w|min-h|max-w|max-h|font|leading|tracking|shadow|z|opacity|transition|duration|ease|animate|backdrop|ring|outline|uppercase|italic|hidden|block|inline|absolute|relative|fixed|sticky|top|left|right|bottom|inset|items|justify|self|space|overflow|cursor|select|pointer|translate|scale|rotate|blur|from|to|via|fill|stroke)(?:-[\w.\[\]#%/():,-]+)?(?=\s|$)/;
|
|
18
|
+
|
|
19
|
+
/** Split a class token into its variants and its utility, respecting [arbitrary:values]. */
|
|
20
|
+
export function splitClass(token) {
|
|
21
|
+
let t = token.replace(/^!/, "").replace(/!$/, "");
|
|
22
|
+
let depth = 0;
|
|
23
|
+
let cut = -1;
|
|
24
|
+
for (let i = 0; i < t.length; i++) {
|
|
25
|
+
if (t[i] === "[" || t[i] === "(") depth++;
|
|
26
|
+
else if (t[i] === "]" || t[i] === ")") depth--;
|
|
27
|
+
else if (t[i] === ":" && depth === 0) cut = i;
|
|
28
|
+
}
|
|
29
|
+
return { variants: cut < 0 ? [] : t.slice(0, cut).split(":"), base: cut < 0 ? t : t.slice(cut + 1), raw: token };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function balanced(text, open, openCh, closeCh) {
|
|
33
|
+
let depth = 0;
|
|
34
|
+
let quote = null;
|
|
35
|
+
for (let i = open; i < text.length; i++) {
|
|
36
|
+
const c = text[i];
|
|
37
|
+
if (quote) {
|
|
38
|
+
if (c === "\\") i++;
|
|
39
|
+
else if (c === quote) quote = null;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (c === '"' || c === "'" || c === "`") quote = c;
|
|
43
|
+
else if (c === openCh) depth++;
|
|
44
|
+
else if (c === closeCh && --depth === 0) return i;
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Static parts of a template literal body; ${…} becomes spaces of equal length. */
|
|
50
|
+
const staticParts = (s) => s.replace(/\$\{[^}]*\}/g, (m) => " ".repeat(m.length));
|
|
51
|
+
|
|
52
|
+
export function readMarkup(source, ext) {
|
|
53
|
+
// Blank JS comments only in script-like files; in HTML `//` is just text.
|
|
54
|
+
const isHtmlLike = ext === ".html" || ext === ".vue" || ext === ".svelte" || ext === ".astro";
|
|
55
|
+
let text = blankComments(source);
|
|
56
|
+
if (!isHtmlLike) text = text.replace(/(^|[^:"'`\\])\/\/[^\n]*/g, (m, pre) => pre + " ".repeat(m.length - pre.length));
|
|
57
|
+
text = text.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
|
|
58
|
+
const at = lineIndex(text);
|
|
59
|
+
|
|
60
|
+
const classLists = []; // { value, offset, line, tokens: [{...splitClass, line}] }
|
|
61
|
+
const seen = new Set();
|
|
62
|
+
const addList = (value, offset, origin) => {
|
|
63
|
+
if (seen.has(offset)) return;
|
|
64
|
+
seen.add(offset);
|
|
65
|
+
const tokens = [];
|
|
66
|
+
const re = /\S+/g;
|
|
67
|
+
let m;
|
|
68
|
+
while ((m = re.exec(value))) tokens.push({ ...splitClass(m[0]), line: at(offset + m.index) });
|
|
69
|
+
if (tokens.length) classLists.push({ value, offset, line: at(offset), tokens, origin, tag: origin === "attribute" ? tagAround(offset, value.length) : "" });
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** The opening tag a class attribute sits in, for rules that need its element or attributes. */
|
|
73
|
+
const tagAround = (offset, length) => {
|
|
74
|
+
let lt = offset;
|
|
75
|
+
while (lt > 0 && !(text[lt] === "<" && /[A-Za-z]/.test(text[lt + 1] ?? ""))) lt--;
|
|
76
|
+
const rest = text.slice(offset + length, offset + length + 600);
|
|
77
|
+
const gt = rest.search(/(?<![=-])>/);
|
|
78
|
+
return text.slice(lt, offset + length + (gt < 0 ? 0 : gt + 1));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (const m of text.matchAll(CLASS_ATTR)) {
|
|
82
|
+
const idx = m.slice(1).findIndex((g) => g !== undefined);
|
|
83
|
+
const value = m[idx + 1];
|
|
84
|
+
const offset = m.index + m[0].indexOf(value, m[0].indexOf("="));
|
|
85
|
+
addList(idx === 4 ? staticParts(value) : value, offset, "attribute");
|
|
86
|
+
}
|
|
87
|
+
for (const m of text.matchAll(CLASS_CALL)) {
|
|
88
|
+
const open = m.index + m[0].length - 1;
|
|
89
|
+
const close = balanced(text, open, "(", ")");
|
|
90
|
+
if (close < 0) continue;
|
|
91
|
+
const args = text.slice(open + 1, close);
|
|
92
|
+
for (const s of args.matchAll(/(["'`])((?:(?!\1)[^\\]|\\.)*)\1/g)) {
|
|
93
|
+
addList(s[1] === "`" ? staticParts(s[2]) : s[2], open + 1 + s.index + 1, "helper");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!isHtmlLike || ext !== ".html") {
|
|
97
|
+
for (const m of text.matchAll(EXPR_STRING)) {
|
|
98
|
+
const value = m[2];
|
|
99
|
+
if (value.length < 3 || !CLASSY.test(value) || !TAILWINDISH.test(value)) continue;
|
|
100
|
+
if (/^[a-z]+(-[a-z]+)*$/.test(value) && !/\s/.test(value) && !/^(uppercase|italic|transition-all|h-screen|min-h-screen|outline-none)$/.test(value)) continue;
|
|
101
|
+
addList(m[1] === "`" ? staticParts(value) : value, m.index + m[0].lastIndexOf(m[1] + value) + 1, "string");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Styles: <style> blocks, style="…" attributes, style={{…}} objects, CSS-in-JS templates.
|
|
106
|
+
const css = [];
|
|
107
|
+
for (const m of text.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi)) {
|
|
108
|
+
const body = source.slice(m.index + m[0].indexOf(">") + 1, m.index + m[0].lastIndexOf("</"));
|
|
109
|
+
css.push(parseCss(body, at(m.index + m[0].indexOf(">") + 1)));
|
|
110
|
+
}
|
|
111
|
+
const inline = [];
|
|
112
|
+
for (const m of text.matchAll(/\bstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) {
|
|
113
|
+
const body = m[1] ?? m[2];
|
|
114
|
+
if (body.includes(":")) inline.push(...parseInlineStyle(body, at(m.index)));
|
|
115
|
+
}
|
|
116
|
+
for (const m of text.matchAll(/\bstyle\s*=\s*\{\{/g)) {
|
|
117
|
+
const open = m.index + m[0].length - 1;
|
|
118
|
+
const close = balanced(text, open, "{", "}");
|
|
119
|
+
if (close < 0) continue;
|
|
120
|
+
const body = text.slice(open + 1, close);
|
|
121
|
+
for (const p of body.matchAll(/([A-Za-z]+)\s*:\s*("[^"]*"|'[^']*'|`[^`]*`|-?[\d.]+)/g)) {
|
|
122
|
+
const prop = p[1].replace(/[A-Z]/g, (c) => "-" + c.toLowerCase());
|
|
123
|
+
const value = p[2].replace(/^["'`]|["'`]$/g, "");
|
|
124
|
+
inline.push({ prop, value, line: at(open + 1 + p.index), selector: "[style]", atRules: [], block: null, inline: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const m of text.matchAll(/\b(?:styled(?:\.\w+|\([^)]*\))|css|createGlobalStyle|keyframes|injectGlobal)\s*`([^`]*)`/g)) {
|
|
128
|
+
const bodyOffset = m.index + m[0].indexOf("`") + 1;
|
|
129
|
+
css.push(parseCss(`x{${staticParts(m[1])}}`, at(bodyOffset)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Visible text of buttons and links, for the copy rules.
|
|
133
|
+
const labels = [];
|
|
134
|
+
for (const m of text.matchAll(/<(button|Button|a|Link)\b[^>]*>\s*([^<>{}]+?)\s*<\/\1>/g)) {
|
|
135
|
+
labels.push({ element: m[1], text: m[2].replace(/\s+/g, " ").trim(), line: at(m.index + m[0].indexOf(m[2])) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Navigation blocks: how many links each writes out, and whether it groups them.
|
|
139
|
+
// Links rendered from an array (`items.map`) are invisible here, so the count is a floor.
|
|
140
|
+
const navs = [];
|
|
141
|
+
for (const m of text.matchAll(/<nav\b[^>]*>([\s\S]*?)<\/nav>/g)) {
|
|
142
|
+
const body = m[1];
|
|
143
|
+
const links = (body.match(/<(?:a|Link|NavLink)\b/g) ?? []).length;
|
|
144
|
+
const grouped = /<details\b|<h[2-6]\b|role\s*=\s*["']group|<section\b|<fieldset\b/.test(body) || (body.match(/<ul\b/g) ?? []).length > 1;
|
|
145
|
+
navs.push({ line: at(m.index), links, grouped });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { text, classLists, css, inline, labels, navs };
|
|
149
|
+
}
|