@echelon-foundry/design-system 0.12.0 → 0.13.0-main.264.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.
@@ -1,4 +1,4 @@
1
- /*! Forma 0.12.0 | forma-marketing.css | bundle echelon-marketing
1
+ /*! Forma 0.13.0 | forma-marketing.css | bundle echelon-marketing
2
2
  Brand-neutral marketing layer: core tokens, role defaults, Forma foundations, marketing foundations, components, and layouts.
3
3
  Generated by tools/PresentationBundler. Do not edit; pin by version and sha256. */
4
4
 
@@ -902,6 +902,20 @@
902
902
  outline: 3px solid var(--ef-color-focus-gap, #f2efe7);
903
903
  outline-offset: 2px;
904
904
  box-shadow: 0 0 0 5px var(--ef-color-focus-ring, #171a18);
905
+ /* Focus scrolling brings the whole indicator into view, not only the
906
+ element's border box (LRP-001). Changes scroll position only. */
907
+ scroll-margin: 0.5rem;
908
+ }
909
+
910
+ /* Keyboard focus on a date or time control's own picker button leaves the
911
+ host input :focus-within but neither :focus nor :focus-visible
912
+ (Chromium), which painted no indicator at all (LRP-001). :not(:focus)
913
+ limits this to that case: pointer focus on the input itself (WebKit)
914
+ keeps the native behaviour. */
915
+ :where(input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"]):focus-within:not(:focus) {
916
+ outline: 3px solid var(--ef-color-focus-gap, #f2efe7);
917
+ outline-offset: 2px;
918
+ box-shadow: 0 0 0 5px var(--ef-color-focus-ring, #171a18);
905
919
  }
906
920
 
907
921
  .ef-stack { display: flex; flex-direction: column; gap: var(--ef-stack-gap, var(--ef-primitive-spacing-4, 1rem)); }
@@ -919,6 +933,39 @@
919
933
  overflow-wrap: anywhere;
920
934
  }
921
935
 
936
+ /*
937
+ * Layout reliability (LRP-001, docs/reliability/layout-baseline.md).
938
+ * Forma containers that hold arbitrary application content pass
939
+ * overflow-wrap: break-word to that content by inheritance, so a long token
940
+ * in a span, strong, em, div or li breaks instead of escaping the card,
941
+ * dialog or field. break-word, unlike anywhere, leaves min-content sizes
942
+ * alone: a word that fits is never split, and auto-sized flex and grid
943
+ * items keep whole words. Inheritance never overrides a declared value:
944
+ * text elements above keep anywhere, character-grid runs keep normal, and
945
+ * white-space: pre or nowrap content (code blocks, truncating selects,
946
+ * scrollers) does not wrap.
947
+ */
948
+ :where(
949
+ .ef-surface, .ef-stack, .ef-cluster, .ef-actions, .ef-grid, .ef-responsive-grid,
950
+ .ef-rail, .ef-mosaic, .ef-priority-stack, .ef-focus-stage, .ef-sidebar, .ef-switcher, .ef-split-pane,
951
+ .ef-dialog, .ef-popover, .ef-menu, .ef-form, .ef-field, .ef-validation-summary, .ef-record-header
952
+ ) {
953
+ overflow-wrap: break-word;
954
+ }
955
+
956
+ /*
957
+ * A wrapping row's item can be no wider than the row (LRP-001), which also
958
+ * caps its automatic flex minimum, so a long label or an inline-block item
959
+ * cannot push the row past its container; the item's text then breaks
960
+ * inside it. Items keep their content size whenever it fits: nothing here
961
+ * shrinks a control below its label. Top-layer children (a dialog or
962
+ * popover authored inside the row) keep their own sizing. A full-bleed
963
+ * child with negative margins is capped at the row's width.
964
+ */
965
+ :where(.ef-cluster, .ef-actions) > :not(dialog, [popover]) {
966
+ max-inline-size: 100%;
967
+ }
968
+
922
969
  /* Action labels never split mid-word because their container allows it
923
970
  (a link button inherits the rule above; a button inherits it from a
924
971
  table cell). A single word wider than its container may still break
@@ -1305,6 +1352,23 @@
1305
1352
  overflow-wrap: anywhere;
1306
1353
  }
1307
1354
 
1355
+ /*
1356
+ * Marketing containment (MKT-CONTAIN, WI-0043). LRP-001 passes
1357
+ * overflow-wrap: break-word to arbitrary content inside the application
1358
+ * containers; marketing components hold the same kind of content (list
1359
+ * items, spans and divs that the element rule above does not reach), so
1360
+ * they pass it on too. break-word never splits a word that fits and leaves
1361
+ * min-content sizes alone; declared values (badges, steps, code) win over
1362
+ * inheritance. Found by the EDF site at 320px: list words escaped a card at
1363
+ * 200% text and a release URL in a span escaped it at every width.
1364
+ */
1365
+ .ef-site :where(
1366
+ .ef-hero, .ef-section-heading, .ef-card, .ef-facts, .ef-index, .ef-callout,
1367
+ .ef-cta, .ef-split, .ef-doc, .ef-prose, .ef-statement, .ef-site-footer
1368
+ ) {
1369
+ overflow-wrap: break-word;
1370
+ }
1371
+
1308
1372
  .ef-site :where(hr) {
1309
1373
  margin-block: var(--ef-layout-section-space);
1310
1374
  border: 0;
@@ -1887,6 +1951,10 @@
1887
1951
 
1888
1952
  .ef-cta[data-ef-tone]:not([data-ef-tone="page"]) {
1889
1953
  padding: calc(var(--ef-layout-block-space) * 2);
1954
+ /* Same cap as a toned .ef-section (ULI-L05): at 320px and 200% text the
1955
+ uncapped inline padding left a 96px content box (MKT-CONTAIN). It
1956
+ changes nothing at the default text size. */
1957
+ padding-inline: min(calc(var(--ef-layout-block-space) * 2), 12.5vw);
1890
1958
  border-block-start: 0;
1891
1959
  }
1892
1960
 
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "forma": "0.12.0",
3
+ "forma": "0.13.0",
4
4
  "bundle": "echelon-marketing",
5
5
  "description": "Forma presentation artifacts for Echelon marketing sites. Every artifact is a single self-contained CSS file with no imports or relative URLs.",
6
- "releaseTag": "v0.12.0",
6
+ "releaseTag": "v0.13.0",
7
7
  "artifacts": [
8
8
  {
9
9
  "file": "forma-marketing.css",
10
10
  "description": "Brand-neutral marketing layer: core tokens, role defaults, Forma foundations, marketing foundations, components, and layouts.",
11
- "bytes": 92562,
12
- "sha256": "e945b2e9f5396108446be83c8b0af50b64487b5749fc35a05e3b8582090dbd5f",
11
+ "bytes": 96137,
12
+ "sha256": "08c70e579ad93f28d2dedf5d4c5e1b7d455773acff50b19a2a688f132123b9df",
13
13
  "sources": [
14
14
  "dist/tokens.css",
15
15
  "src/marketing/roles.css",
@@ -23,7 +23,7 @@
23
23
  "file": "forma-marketing-theme-echelon.css",
24
24
  "description": "Echelon Marketing Theme: compiled theme tokens and theme hooks. Load after forma-marketing.css.",
25
25
  "bytes": 6296,
26
- "sha256": "495b81a7dae75204ac9fcd2531754a2e0fa6e912009430d78d97d87807104402",
26
+ "sha256": "0b803cade4de294a2e15ac8a8482cb2448617a2558d70fc6542a4b3b9a357bf2",
27
27
  "sources": [
28
28
  "build/marketing/echelon-marketing.tokens.css",
29
29
  "themes/echelon/marketing.css"
@@ -32,8 +32,8 @@
32
32
  {
33
33
  "file": "forma-echelon-marketing.css",
34
34
  "description": "Single-file Echelon marketing presentation: forma-marketing.css followed by the Echelon theme. Most sites load only this file.",
35
- "bytes": 98702,
36
- "sha256": "f93946ab2401d3a65f10ce96bbea8c830ad1c239bcc412c8f7906dfba8f6616c",
35
+ "bytes": 102277,
36
+ "sha256": "d440c969a3defb5e8eb51043da8b66d3faa29094a429ece6a046deeea98377cb",
37
37
  "sources": [
38
38
  "artifact:forma-marketing.css",
39
39
  "artifact:forma-marketing-theme-echelon.css"
@@ -42,8 +42,8 @@
42
42
  {
43
43
  "file": "forma-all.css",
44
44
  "description": "Complete Forma surface (application components, assessment, and marketing layers) for sites that also present Forma application patterns such as data grids or status lozenges.",
45
- "bytes": 342064,
46
- "sha256": "8126a388b0563f6d7f40a84058035bfd6cd62e966c181ff3c055f530bc2cc32c",
45
+ "bytes": 346402,
46
+ "sha256": "0ba4ce2e6ef70b94000a725780d90e0061b312960265387bc3e2e06c34b92484",
47
47
  "sources": [
48
48
  "dist/all.css"
49
49
  ]
@@ -1,4 +1,4 @@
1
- e945b2e9f5396108446be83c8b0af50b64487b5749fc35a05e3b8582090dbd5f forma-marketing.css
2
- 495b81a7dae75204ac9fcd2531754a2e0fa6e912009430d78d97d87807104402 forma-marketing-theme-echelon.css
3
- f93946ab2401d3a65f10ce96bbea8c830ad1c239bcc412c8f7906dfba8f6616c forma-echelon-marketing.css
4
- 8126a388b0563f6d7f40a84058035bfd6cd62e966c181ff3c055f530bc2cc32c forma-all.css
1
+ 08c70e579ad93f28d2dedf5d4c5e1b7d455773acff50b19a2a688f132123b9df forma-marketing.css
2
+ 0b803cade4de294a2e15ac8a8482cb2448617a2558d70fc6542a4b3b9a357bf2 forma-marketing-theme-echelon.css
3
+ d440c969a3defb5e8eb51043da8b66d3faa29094a429ece6a046deeea98377cb forma-echelon-marketing.css
4
+ 0ba4ce2e6ef70b94000a725780d90e0061b312960265387bc3e2e06c34b92484 forma-all.css
@@ -285,6 +285,23 @@
285
285
  overflow-wrap: anywhere;
286
286
  }
287
287
 
288
+ /*
289
+ * Marketing containment (MKT-CONTAIN, WI-0043). LRP-001 passes
290
+ * overflow-wrap: break-word to arbitrary content inside the application
291
+ * containers; marketing components hold the same kind of content (list
292
+ * items, spans and divs that the element rule above does not reach), so
293
+ * they pass it on too. break-word never splits a word that fits and leaves
294
+ * min-content sizes alone; declared values (badges, steps, code) win over
295
+ * inheritance. Found by the EDF site at 320px: list words escaped a card at
296
+ * 200% text and a release URL in a span escaped it at every width.
297
+ */
298
+ .ef-site :where(
299
+ .ef-hero, .ef-section-heading, .ef-card, .ef-facts, .ef-index, .ef-callout,
300
+ .ef-cta, .ef-split, .ef-doc, .ef-prose, .ef-statement, .ef-site-footer
301
+ ) {
302
+ overflow-wrap: break-word;
303
+ }
304
+
288
305
  .ef-site :where(hr) {
289
306
  margin-block: var(--ef-layout-section-space);
290
307
  border: 0;
@@ -866,6 +883,10 @@
866
883
 
867
884
  .ef-cta[data-ef-tone]:not([data-ef-tone="page"]) {
868
885
  padding: calc(var(--ef-layout-block-space) * 2);
886
+ /* Same cap as a toned .ef-section (ULI-L05): at 320px and 200% text the
887
+ uncapped inline padding left a 96px content box (MKT-CONTAIN). It
888
+ changes nothing at the default text size. */
889
+ padding-inline: min(calc(var(--ef-layout-block-space) * 2), 12.5vw);
869
890
  border-block-start: 0;
870
891
  }
871
892
 
package/dist/runtime.js CHANGED
@@ -52,6 +52,6 @@ export function registerEchelonRuntime(name, metadata, target = globalThis) {
52
52
  return installEchelonRuntimeRegistrar(target)?.(name, metadata);
53
53
  }
54
54
 
55
- export const FORMA_VERSION = "0.12.0";
55
+ export const FORMA_VERSION = "0.13.0";
56
56
 
57
57
  if (typeof window !== "undefined") registerEchelonRuntime("forma", { version: FORMA_VERSION }, window);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echelon-foundry/design-system",
3
- "version": "0.12.0",
3
+ "version": "0.13.0-main.264.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Zero-component-runtime HTML and CSS design system for Echelon Foundry applications.",
@@ -47,6 +47,8 @@
47
47
  "docs/MARKETING-SITES.md",
48
48
  "tokens/echelon.tokens.json",
49
49
  "tools/color-contract",
50
+ "tools/TokenValidator/*.fs",
51
+ "tools/TokenValidator/TokenValidator.fsproj",
50
52
  "contracts/color-contract.json",
51
53
  "docs/COLOR.md",
52
54
  "figma/component-contracts.json",
@@ -71,7 +73,8 @@
71
73
  "assets:build": "node tools/build-assets.mjs",
72
74
  "build": "npm run clean && npm run tokens:build && npm run brands:build && npm run color:build && npm run marketing:theme && npm run runtime:build && npm run assets:build && npm run marketing:build && npm run example:install",
73
75
  "runtime:build": "node tools/build-runtime.mjs",
74
- "test:tokens": "node --test tests/token-compiler.test.mjs",
76
+ "test:tokens": "node --test tests/token-compiler.test.mjs tests/token-validator.test.mjs",
77
+ "tokens:validate": "dotnet run --project tools/TokenValidator/TokenValidator.fsproj -c Release -- --self",
75
78
  "test:browser": "playwright test",
76
79
  "test:a11y": "playwright test tests/browser/accessibility.spec.mjs",
77
80
  "test:runtime": "node --test tests/zero-runtime.test.mjs",
@@ -79,7 +82,7 @@
79
82
  "test:color": "node --test tests/color-contract.test.mjs",
80
83
  "motion:audit": "node tools/motion-audit.mjs",
81
84
  "test:motion": "node --test tests/motion-audit.test.mjs tests/motion-gate.test.mjs && node tools/motion-audit.mjs --strict",
82
- "check": "npm run build && npm run icons:check && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:color && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:figma && npm run test:css && npm run test:package && npm run workflow:test && npm run workflow:build && npm run test:workflow-package && npm run test:browser",
85
+ "check": "npm run build && npm run icons:check && npm run tokens:validate && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:color && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:figma && npm run test:css && npm run test:package && npm run workflow:test && npm run workflow:build && npm run test:workflow-package && npm run test:browser",
83
86
  "site:build": "npm run build && node tools/build-site.mjs",
84
87
  "site:test": "node --test tests/site-build.test.mjs tests/catalog-coverage.test.mjs tests/icon-docs.test.mjs tests/catalog-interaction.test.mjs",
85
88
  "site:check": "npm run site:build && npm run catalog:check && npm run site:test && npm run site:mobile",
@@ -97,7 +100,7 @@
97
100
  "test:marketing": "node --test tests/marketing-tokens.test.mjs tests/marketing-distribution.test.mjs tests/marketing-structure.test.mjs tests/site-css-policy.test.mjs",
98
101
  "example:install": "mkdir -p build/example && cp examples/echelon-marketing-site/forma.lock build/example/forma.lock && bash actions/install-presentation/install.sh --lock build/example/forma.lock --source dist/marketing --update --output examples/echelon-marketing-site/assets/forma",
99
102
  "policy:check": "dotnet run --project tools/SiteCssPolicy/SiteCssPolicy.fsproj --",
100
- "release:check": "npm run build && npm run icons:check && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:css && npm run test:package && npm run test:machine && playwright test tests/browser/marketing.spec.mjs && npm run site:check",
103
+ "release:check": "npm run build && npm run icons:check && npm run tokens:validate && npm run test:tokens && npm run test:brands && npm run test:runtime && npm run test:design && npm run test:motion && npm run test:marketing && npm run test:character-grid && npm run test:css && npm run test:package && npm run test:machine && playwright test tests/browser/marketing.spec.mjs && npm run site:check",
101
104
  "test:figma": "node --test tests/figma-contract.test.mjs",
102
105
  "catalog:check": "node tools/catalog/validate.mjs && node tools/catalog/inventory.mjs --check",
103
106
  "catalog:inventory": "node tools/catalog/inventory.mjs",
@@ -0,0 +1,171 @@
1
+ /// A minimal, dependency-free CSS reader: enough structure to find custom
2
+ /// property declarations and var() references with their selector, file and
3
+ /// line. It is not a validating parser; malformed CSS still yields whatever
4
+ /// declarations can be recognised.
5
+ module Forma.TokenValidator.Css
6
+
7
+ open System
8
+ open System.Text
9
+
10
+ type VarReference =
11
+ { Name: string
12
+ HasFallback: bool }
13
+
14
+ type Declaration =
15
+ { File: string
16
+ Line: int
17
+ Selector: string
18
+ /// Every enclosing prelude, outermost first (at-rules and selectors).
19
+ Context: string
20
+ Property: string
21
+ Value: string
22
+ References: VarReference list }
23
+
24
+ /// Replace comments with spaces and blank the contents of quoted strings,
25
+ /// keeping their quotes and every newline, so offsets and line numbers still
26
+ /// match the source. Braces, comment markers and var() inside a string
27
+ /// (content: "}") are then inert.
28
+ let normalise (text: string) =
29
+ let builder = StringBuilder(text.Length)
30
+ let blank (c: char) = if c = '\n' then '\n' else ' '
31
+ let rec go index (state: char option) inComment =
32
+ if index >= text.Length then ()
33
+ elif inComment then
34
+ if index + 1 < text.Length && text[index] = '*' && text[index + 1] = '/' then
35
+ builder.Append(" ") |> ignore
36
+ go (index + 2) None false
37
+ else
38
+ builder.Append(blank text[index]) |> ignore
39
+ go (index + 1) None true
40
+ else
41
+ match state with
42
+ | Some _ when text[index] = '\\' && index + 1 < text.Length ->
43
+ builder.Append(blank text[index]).Append(blank text[index + 1]) |> ignore
44
+ go (index + 2) state false
45
+ | Some quote when text[index] = quote ->
46
+ builder.Append(quote) |> ignore
47
+ go (index + 1) None false
48
+ | Some _ ->
49
+ builder.Append(blank text[index]) |> ignore
50
+ go (index + 1) state false
51
+ | None when index + 1 < text.Length && text[index] = '/' && text[index + 1] = '*' ->
52
+ builder.Append(" ") |> ignore
53
+ go (index + 2) None true
54
+ | None when text[index] = '"' || text[index] = '\'' ->
55
+ builder.Append(text[index]) |> ignore
56
+ go (index + 1) (Some text[index]) false
57
+ | None ->
58
+ builder.Append(text[index]) |> ignore
59
+ go (index + 1) None false
60
+ go 0 None false
61
+ builder.ToString()
62
+
63
+ let private isNameChar (c: char) = Char.IsLetterOrDigit c || c = '-' || c = '_'
64
+
65
+ /// Index of the parenthesis closing the one opened just before `start`.
66
+ let private closingParen (value: string) start =
67
+ let rec go index depth =
68
+ if index >= value.Length then value.Length
69
+ else
70
+ match value[index] with
71
+ | '(' -> go (index + 1) (depth + 1)
72
+ | ')' when depth = 0 -> index
73
+ | ')' -> go (index + 1) (depth - 1)
74
+ | _ -> go (index + 1) depth
75
+ go start 0
76
+
77
+ /// Every var(--name[, fallback]) in a value, including those nested in
78
+ /// fallbacks, in source order.
79
+ let rec references (value: string) : VarReference list =
80
+ let index = value.IndexOf("var(", StringComparison.OrdinalIgnoreCase)
81
+ if index < 0 then []
82
+ else
83
+ let start = index + 4
84
+ let close = closingParen value start
85
+ let inner = value.Substring(start, close - start)
86
+ let nameStart = inner.Length - inner.TrimStart().Length
87
+ let name = inner.Substring(nameStart) |> Seq.takeWhile isNameChar |> Seq.toArray |> String
88
+ let rest = inner.Substring(nameStart + name.Length).TrimStart()
89
+ let fallback = if rest.StartsWith(",") then rest.Substring(1) else ""
90
+ let own =
91
+ if name.StartsWith("--") then [ { Name = name; HasFallback = rest.StartsWith(",") } ] else []
92
+ own @ references fallback @ references (value.Substring(min value.Length (close + 1)))
93
+
94
+ /// Offsets at which each line starts, for line lookup by binary search.
95
+ let private lineStarts (text: string) =
96
+ Array.append [| 0 |] (text |> Seq.indexed |> Seq.filter (fun (_, c) -> c = '\n') |> Seq.map (fun (i, _) -> i + 1) |> Seq.toArray)
97
+
98
+ let private lineAt (starts: int array) offset =
99
+ let found = Array.BinarySearch(starts, offset)
100
+ if found >= 0 then found + 1 else ~~~found
101
+
102
+ /// Split a block body at top-level semicolons, keeping each piece's offset.
103
+ let private pieces (body: string) (offset: int) =
104
+ let rec go index depth quote start acc =
105
+ if index >= body.Length then List.rev ((start, body.Substring(start)) :: acc)
106
+ else
107
+ let c = body[index]
108
+ match quote with
109
+ | Some q when c = q -> go (index + 1) depth None start acc
110
+ | Some _ -> go (index + 1) depth quote start acc
111
+ | None ->
112
+ match c with
113
+ | '"' | '\'' -> go (index + 1) depth (Some c) start acc
114
+ | '(' -> go (index + 1) (depth + 1) None start acc
115
+ | ')' -> go (index + 1) (max 0 (depth - 1)) None start acc
116
+ | ';' when depth = 0 -> go (index + 1) depth None (index + 1) ((start, body.Substring(start, index - start)) :: acc)
117
+ | _ -> go (index + 1) depth None start acc
118
+ go 0 0 None 0 [] |> List.map (fun (start, piece) -> offset + start, piece)
119
+
120
+ /// Declarations of one file. Nested blocks (@media, @layer, nesting) are
121
+ /// walked; a declaration's selector is the innermost enclosing prelude.
122
+ let declarations (file: string) (source: string) : Declaration list =
123
+ let text = normalise source
124
+ let starts = lineStarts text
125
+ let declarationOf (stack: string list) (offset: int, piece: string) =
126
+ let selector = match stack with innermost :: _ -> innermost | [] -> ""
127
+ let colon = piece.IndexOf(':')
128
+ let property = if colon > 0 then piece.Substring(0, colon).Trim() else ""
129
+ if colon <= 0 || property.Length = 0 || property.Contains("{") || property.Contains("}") || property.StartsWith("@") then None
130
+ else
131
+ let value = piece.Substring(colon + 1).Trim()
132
+ let leading = piece.Length - piece.TrimStart().Length
133
+ Some
134
+ { File = file
135
+ Line = lineAt starts (offset + leading)
136
+ Selector = selector
137
+ Context = stack |> List.rev |> String.concat " » "
138
+ Property = property
139
+ Value = value
140
+ References = references value }
141
+ // Walk the text, splitting at braces. Text between braces at one level is
142
+ // either declarations (inside a block) or a prelude (before '{').
143
+ let rec walk index segmentStart stack acc =
144
+ if index >= text.Length then
145
+ let tail =
146
+ match stack with
147
+ | _ :: _ -> pieces (text.Substring(segmentStart)) segmentStart |> List.choose (declarationOf stack)
148
+ | [] -> []
149
+ List.rev acc @ tail
150
+ else
151
+ match text[index] with
152
+ | '{' ->
153
+ // The segment before '{' may hold declarations followed by a nested prelude.
154
+ let segment = text.Substring(segmentStart, index - segmentStart)
155
+ let lastSemicolon = segment.LastIndexOf(';')
156
+ let declared, prelude =
157
+ if lastSemicolon >= 0 then segment.Substring(0, lastSemicolon + 1), segment.Substring(lastSemicolon + 1)
158
+ else "", segment
159
+ let found =
160
+ match stack with
161
+ | _ :: _ when declared.Length > 0 -> pieces declared segmentStart |> List.choose (declarationOf stack)
162
+ | _ -> []
163
+ walk (index + 1) (index + 1) (prelude.Trim() :: stack) (List.rev found @ acc)
164
+ | '}' ->
165
+ let found =
166
+ match stack with
167
+ | _ :: _ -> pieces (text.Substring(segmentStart, index - segmentStart)) segmentStart |> List.choose (declarationOf stack)
168
+ | [] -> []
169
+ walk (index + 1) (index + 1) (match stack with _ :: rest -> rest | [] -> []) (List.rev found @ acc)
170
+ | _ -> walk (index + 1) segmentStart stack acc
171
+ walk 0 0 [] []
@@ -0,0 +1,104 @@
1
+ module Forma.TokenValidator.Program
2
+
3
+ open System
4
+ open System.IO
5
+ open System.Text.Json
6
+ open Forma.TokenValidator.Validation
7
+
8
+ let private usage =
9
+ """usage: TokenValidator [--forma DIR] [--json] (--self | PATH...)
10
+
11
+ --forma DIR Forma root to validate against: an installed package
12
+ (node_modules/@echelon-foundry/design-system) or a built
13
+ checkout. Default: the current directory.
14
+ --self Validate Forma itself: token sources (aliases, cycles, theme
15
+ completeness), brand manifests, and every var() in the built
16
+ stylesheets.
17
+ PATH... Application CSS files or directories (searched for *.css,
18
+ skipping node_modules) to validate against that Forma version.
19
+ --json Print findings as JSON.
20
+
21
+ Exit status: 0 no errors, 1 errors found, 2 invalid arguments or unreadable input."""
22
+
23
+ type private Options =
24
+ { Forma: string
25
+ Self: bool
26
+ Json: bool
27
+ Paths: string list }
28
+
29
+ let rec private parse (options: Options) (args: string list) =
30
+ match args with
31
+ | [] -> Ok { options with Paths = List.rev options.Paths }
32
+ | "--forma" :: dir :: rest -> parse { options with Forma = dir } rest
33
+ | "--self" :: rest -> parse { options with Self = true } rest
34
+ | "--json" :: rest -> parse { options with Json = true } rest
35
+ | ("--help" | "-h") :: _ -> Error ""
36
+ | flag :: _ when flag.StartsWith("--") -> Error $"unknown option {flag}"
37
+ | path :: rest -> parse { options with Paths = path :: options.Paths } rest
38
+
39
+ let private cssFiles (path: string) =
40
+ if Directory.Exists path then
41
+ Directory.EnumerateFiles(path, "*.css", SearchOption.AllDirectories)
42
+ |> Seq.filter (fun file -> not (file.Split(Path.DirectorySeparatorChar) |> Array.contains "node_modules"))
43
+ |> Seq.sort
44
+ |> Seq.toList
45
+ elif File.Exists path then [ path ]
46
+ else failwith $"no such file or directory: {path}"
47
+
48
+ let private relative (file: string) =
49
+ if String.IsNullOrEmpty file then file else Path.GetRelativePath(Environment.CurrentDirectory, file)
50
+
51
+ let private render json (findings: Finding list) =
52
+ if json then
53
+ let rows =
54
+ findings
55
+ |> List.map (fun f ->
56
+ dict [ "code", box f.Code
57
+ "severity", box (match f.Severity with Blocking -> "error" | Advisory -> "warning")
58
+ "file", box (relative f.File)
59
+ "line", box f.Line
60
+ "name", box f.Name
61
+ "message", box f.Message ])
62
+ printfn "%s" (JsonSerializer.Serialize(rows, JsonSerializerOptions(WriteIndented = true)))
63
+ else
64
+ findings
65
+ |> List.iter (fun f ->
66
+ let location = if f.Line > 0 then $"{relative f.File}:{f.Line}" else relative f.File
67
+ let severity = match f.Severity with Blocking -> "error" | Advisory -> "warning"
68
+ printfn $"{location}: {severity} [{f.Code}] {f.Message}")
69
+
70
+ [<EntryPoint>]
71
+ let main argv =
72
+ match parse { Forma = "."; Self = false; Json = false; Paths = [] } (List.ofArray argv) with
73
+ | Error message ->
74
+ if message <> "" then eprintfn "%s" message
75
+ eprintfn "%s" usage
76
+ if message = "" then 0 else 2
77
+ | Ok options when not options.Self && options.Paths.IsEmpty ->
78
+ eprintfn "%s" usage
79
+ 2
80
+ | Ok options ->
81
+ try
82
+ let root = Path.GetFullPath options.Forma
83
+ let registry = registryOf root
84
+ let selfFindings =
85
+ if options.Self then
86
+ let tokens = validateTokens registry.Tokens
87
+ let brands =
88
+ let directory = Path.Combine(root, "brands")
89
+ if Directory.Exists directory then
90
+ Directory.EnumerateFiles(directory, "*.brand.json") |> Seq.sort |> Seq.toList |> List.collect validateBrand
91
+ else []
92
+ tokens @ brands @ validateFormaCss registry
93
+ else []
94
+ let appFindings =
95
+ options.Paths |> List.collect cssFiles |> declarationsOf |> validateCss registry
96
+ let findings = (selfFindings @ appFindings) |> List.distinct |> List.sortBy (fun f -> f.File, f.Line, f.Name, f.Code)
97
+ render options.Json findings
98
+ let errors = findings |> List.filter (fun f -> f.Severity = Blocking) |> List.length
99
+ if not options.Json then
100
+ eprintfn $"token validation: {errors} error(s); {registry.Defined.Count} defined Forma properties and {registry.Hooks.Count} documented inputs in {relative root}"
101
+ if errors > 0 then 1 else 0
102
+ with ex ->
103
+ eprintfn $"ERROR {ex.Message}"
104
+ 2
@@ -0,0 +1,12 @@
1
+ <Project Sdk="Microsoft.NET.Sdk">
2
+ <PropertyGroup>
3
+ <OutputType>Exe</OutputType>
4
+ <TargetFramework>net10.0</TargetFramework>
5
+ <AssemblyName>forma-token-validator</AssemblyName>
6
+ </PropertyGroup>
7
+ <ItemGroup>
8
+ <Compile Include="Css.fs" />
9
+ <Compile Include="Validation.fs" />
10
+ <Compile Include="Program.fs" />
11
+ </ItemGroup>
12
+ </Project>